Skip to main content

nautilus_databento/
historical.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Core Databento historical client for both Rust and Python usage.
17
18use std::{fmt::Debug, fs, num::NonZeroU64, path::PathBuf, str::FromStr, sync::Arc};
19
20use ahash::AHashMap;
21use databento::{
22    dbn::{self, decode::DbnMetadata},
23    historical::timeseries::GetRangeParams,
24};
25use indexmap::IndexMap;
26use nautilus_core::{AtomicMap, UnixNanos, consts::NAUTILUS_USER_AGENT, time::AtomicTime};
27use nautilus_model::{
28    data::{Bar, Data, InstrumentStatus, OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick},
29    enums::BarAggregation,
30    identifiers::{InstrumentId, Symbol, Venue},
31    instruments::{Instrument, InstrumentAny},
32};
33
34use crate::{
35    common::{Credential, get_date_time_range},
36    decode::{
37        decode_imbalance_msg, decode_instrument_def_msg, decode_mbo_msg, decode_mbp10_msg,
38        decode_record, decode_statistics_msg, decode_status_msg, is_supported_stat_type,
39    },
40    symbology::{
41        MetadataCache, check_consistent_symbology, decode_nautilus_instrument_id,
42        infer_symbology_type,
43    },
44    types::{DatabentoImbalance, DatabentoPublisher, DatabentoStatistics, PublisherId},
45};
46
47/// Core Databento historical client for fetching historical market data.
48///
49/// This client provides both synchronous and asynchronous interfaces for fetching
50/// various types of historical market data from Databento.
51#[derive(Clone)]
52pub struct DatabentoHistoricalClient {
53    credential: Credential,
54    clock: &'static AtomicTime,
55    inner: Arc<databento::HistoricalClient>,
56    publisher_venue_map: Arc<IndexMap<PublisherId, Venue>>,
57    symbol_venue_map: Arc<AtomicMap<Symbol, Venue>>,
58    price_precisions: Arc<AtomicMap<Symbol, u8>>,
59    use_exchange_as_venue: bool,
60}
61
62/// Parameters for range queries to Databento historical API.
63#[derive(Debug)]
64pub struct RangeQueryParams {
65    pub dataset: String,
66    pub symbols: Vec<String>,
67    pub start: UnixNanos,
68    pub end: Option<UnixNanos>,
69    pub limit: Option<u64>,
70    pub price_precision: Option<u8>,
71}
72
73/// Result containing dataset date range information.
74#[derive(Debug, Clone)]
75pub struct DatasetRange {
76    pub start: String,
77    pub end: String,
78}
79
80impl Debug for DatabentoHistoricalClient {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct(stringify!(DatabentoHistoricalClient))
83            .field("credential", &self.credential)
84            .finish()
85    }
86}
87
88impl DatabentoHistoricalClient {
89    /// Returns the API key from the stored credential.
90    #[must_use]
91    pub fn api_key(&self) -> &str {
92        self.credential.api_key()
93    }
94
95    /// Creates a new [`DatabentoHistoricalClient`] instance.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if client creation or publisher loading fails.
100    pub fn new(
101        credential: Credential,
102        publishers_filepath: PathBuf,
103        clock: &'static AtomicTime,
104        use_exchange_as_venue: bool,
105    ) -> anyhow::Result<Self> {
106        let client = databento::HistoricalClient::builder()
107            .user_agent_extension(NAUTILUS_USER_AGENT.into())
108            .key(credential.api_key())
109            .map_err(|e| anyhow::anyhow!("Failed to create client builder: {e}"))?
110            .build()
111            .map_err(|e| anyhow::anyhow!("Failed to build client: {e}"))?;
112
113        Self::from_client(
114            credential,
115            publishers_filepath,
116            clock,
117            use_exchange_as_venue,
118            client,
119        )
120    }
121
122    /// Creates a new [`DatabentoHistoricalClient`] instance with a custom API base URL.
123    ///
124    /// This is intended for tests, benchmarks, and controlled deployments that route
125    /// Databento Historical API requests through a proxy.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if client creation, URL parsing, or publisher loading fails.
130    pub fn new_with_base_url(
131        credential: Credential,
132        publishers_filepath: PathBuf,
133        clock: &'static AtomicTime,
134        use_exchange_as_venue: bool,
135        base_url: &str,
136    ) -> anyhow::Result<Self> {
137        let client = databento::HistoricalClient::builder()
138            .user_agent_extension(NAUTILUS_USER_AGENT.into())
139            .base_url(base_url.parse().map_err(|e| {
140                anyhow::anyhow!("Failed to parse Databento Historical API base URL: {e}")
141            })?)
142            .key(credential.api_key())
143            .map_err(|e| anyhow::anyhow!("Failed to create client builder: {e}"))?
144            .build()
145            .map_err(|e| anyhow::anyhow!("Failed to build client: {e}"))?;
146
147        Self::from_client(
148            credential,
149            publishers_filepath,
150            clock,
151            use_exchange_as_venue,
152            client,
153        )
154    }
155
156    fn from_client(
157        credential: Credential,
158        publishers_filepath: PathBuf,
159        clock: &'static AtomicTime,
160        use_exchange_as_venue: bool,
161        client: databento::HistoricalClient,
162    ) -> anyhow::Result<Self> {
163        let file_content = fs::read_to_string(publishers_filepath)?;
164        let publishers_vec: Vec<DatabentoPublisher> = serde_json::from_str(&file_content)?;
165
166        let publisher_venue_map = publishers_vec
167            .into_iter()
168            .map(|p| (p.publisher_id, Venue::from(p.venue.as_str())))
169            .collect::<IndexMap<u16, Venue>>();
170
171        Ok(Self {
172            clock,
173            inner: Arc::new(client),
174            publisher_venue_map: Arc::new(publisher_venue_map),
175            symbol_venue_map: Arc::new(AtomicMap::new()),
176            price_precisions: Arc::new(AtomicMap::new()),
177            credential,
178            use_exchange_as_venue,
179        })
180    }
181
182    /// Caches a `price_precision` for the given `symbol`.
183    ///
184    /// When market data is fetched without an explicit `price_precision`, the
185    /// client resolves precision per record from this cache. Instruments
186    /// returned by [`Self::get_range_instruments`] are inserted automatically.
187    pub fn set_price_precision(&self, symbol: Symbol, price_precision: u8) {
188        self.price_precisions.insert(symbol, price_precision);
189    }
190
191    /// Returns a cached `price_precision` for the given `symbol`.
192    #[must_use]
193    pub fn price_precision(&self, symbol: Symbol) -> Option<u8> {
194        self.price_precisions.load().get(&symbol).copied()
195    }
196
197    /// Resolves a price precision for the given `instrument_id`.
198    ///
199    /// Resolution order:
200    /// 1. The explicit `price_precision` argument (if `Some`).
201    /// 2. The cached precision for the instrument's symbol.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error when no precision is available.
206    fn resolve_price_precision(
207        &self,
208        instrument_id: &InstrumentId,
209        price_precision: Option<u8>,
210    ) -> anyhow::Result<u8> {
211        if let Some(precision) = price_precision {
212            return Ok(precision);
213        }
214
215        let precisions = self.price_precisions.load();
216        precisions
217            .get(&instrument_id.symbol)
218            .copied()
219            .ok_or_else(|| {
220                anyhow::anyhow!(
221                    "Could not resolve `price_precision` for {instrument_id}: \
222                     pass `price_precision` explicitly, call `set_price_precision`, \
223                     or fetch the instrument definitions first via `get_range_instruments`"
224                )
225            })
226    }
227
228    fn resolve_cached_price_precision(
229        &self,
230        instrument_id: &InstrumentId,
231        price_precision: Option<u8>,
232        precision_cache: &mut AHashMap<InstrumentId, u8>,
233    ) -> anyhow::Result<u8> {
234        if let Some(precision) = price_precision {
235            return Ok(precision);
236        }
237
238        if let Some(precision) = precision_cache.get(instrument_id) {
239            return Ok(*precision);
240        }
241
242        let precision = self.resolve_price_precision(instrument_id, None)?;
243        precision_cache.insert(*instrument_id, precision);
244        Ok(precision)
245    }
246
247    /// Gets the date range for a specific dataset.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the API request fails.
252    pub async fn get_dataset_range(&self, dataset: &str) -> anyhow::Result<DatasetRange> {
253        let mut client = (*self.inner).clone();
254        let response = client
255            .metadata()
256            .get_dataset_range(dataset)
257            .await
258            .map_err(|e| anyhow::anyhow!("Failed to get dataset range: {e}"))?;
259
260        Ok(DatasetRange {
261            start: response.start.to_string(),
262            end: response.end.to_string(),
263        })
264    }
265
266    /// Fetches instrument definitions for the given parameters.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the API request or data processing fails.
271    pub async fn get_range_instruments(
272        &self,
273        params: RangeQueryParams,
274    ) -> anyhow::Result<Vec<InstrumentAny>> {
275        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
276        check_consistent_symbology(&symbols)?;
277
278        let first_symbol = params
279            .symbols
280            .first()
281            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
282        let stype_in = infer_symbology_type(first_symbol);
283        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
284        let time_range = get_date_time_range(params.start, end)?;
285
286        let range_params = GetRangeParams::builder()
287            .dataset(params.dataset)
288            .date_time_range(time_range)
289            .symbols(symbols)
290            .stype_in(stype_in)
291            .schema(dbn::Schema::Definition)
292            .maybe_limit(params.limit.and_then(NonZeroU64::new))
293            .build();
294
295        let mut client = (*self.inner).clone();
296        let mut decoder = client
297            .timeseries()
298            .get_range(&range_params)
299            .await
300            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
301
302        let metadata = decoder.metadata().clone();
303        let mut metadata_cache = MetadataCache::new(metadata);
304        let mut instruments = Vec::new();
305
306        while let Some(msg) = decoder.decode_record::<dbn::InstrumentDefMsg>().await? {
307            let record = dbn::RecordRef::from(msg);
308            let sym_map = self.symbol_venue_map.load();
309            let mut instrument_id = decode_nautilus_instrument_id(
310                &record,
311                &mut metadata_cache,
312                &self.publisher_venue_map,
313                &sym_map,
314            )?;
315
316            if self.use_exchange_as_venue && instrument_id.venue == Venue::GLBX() {
317                let exchange = msg
318                    .exchange()
319                    .map_err(|e| anyhow::anyhow!("Missing exchange in record: {e}"))?;
320                let venue = Venue::from_code(exchange)
321                    .map_err(|e| anyhow::anyhow!("Venue not found for exchange {exchange}: {e}"))?;
322                instrument_id.venue = venue;
323            }
324
325            match decode_instrument_def_msg(msg, instrument_id, None, None) {
326                Ok(Some(instrument)) => instruments.push(instrument),
327                Ok(None) => {} // Decoder logged a warning for the unsupported class
328                Err(e) => anyhow::bail!("Failed to decode instrument {instrument_id}: {e}"),
329            }
330        }
331
332        for instrument in &instruments {
333            self.price_precisions
334                .insert(instrument.id().symbol, instrument.price_precision());
335        }
336
337        Ok(instruments)
338    }
339
340    /// Fetches quote ticks for the given parameters.
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if the API request or data processing fails.
345    pub async fn get_range_quotes(
346        &self,
347        params: RangeQueryParams,
348        schema: Option<String>,
349    ) -> anyhow::Result<Vec<QuoteTick>> {
350        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
351        check_consistent_symbology(&symbols)?;
352
353        let first_symbol = params
354            .symbols
355            .first()
356            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
357        let stype_in = infer_symbology_type(first_symbol);
358        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
359        let time_range = get_date_time_range(params.start, end)?;
360        let schema = schema.unwrap_or_else(|| "mbp-1".to_string());
361        let dbn_schema = dbn::Schema::from_str(&schema)?;
362
363        match dbn_schema {
364            dbn::Schema::Mbp1
365            | dbn::Schema::Tbbo
366            | dbn::Schema::Bbo1S
367            | dbn::Schema::Bbo1M
368            | dbn::Schema::Cmbp1
369            | dbn::Schema::Tcbbo
370            | dbn::Schema::Cbbo1S
371            | dbn::Schema::Cbbo1M => (),
372            _ => anyhow::bail!(
373                "Invalid schema. Must be one of: mbp-1, tbbo, bbo-1s, bbo-1m, cmbp-1, tcbbo, cbbo-1s, cbbo-1m"
374            ),
375        }
376
377        let range_params = GetRangeParams::builder()
378            .dataset(params.dataset)
379            .date_time_range(time_range)
380            .symbols(symbols)
381            .stype_in(stype_in)
382            .schema(dbn_schema)
383            .maybe_limit(params.limit.and_then(NonZeroU64::new))
384            .build();
385
386        let price_precision_arg = params.price_precision;
387
388        let mut client = (*self.inner).clone();
389        let mut decoder = client
390            .timeseries()
391            .get_range(&range_params)
392            .await
393            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
394
395        let metadata = decoder.metadata().clone();
396        let mut metadata_cache = MetadataCache::new(metadata);
397        let mut precision_cache = AHashMap::new();
398        let mut result: Vec<QuoteTick> = Vec::new();
399
400        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
401            let sym_map = self.symbol_venue_map.load();
402            let instrument_id = decode_nautilus_instrument_id(
403                &record,
404                &mut metadata_cache,
405                &self.publisher_venue_map,
406                &sym_map,
407            )?;
408            let price_precision = self.resolve_cached_price_precision(
409                &instrument_id,
410                price_precision_arg,
411                &mut precision_cache,
412            )?;
413
414            let (data, _) = decode_record(
415                &record,
416                instrument_id,
417                price_precision,
418                None,
419                false, // Don't include trades
420                true,
421            )?;
422
423            match data {
424                Some(Data::Quote(quote)) => result.push(quote),
425                None => {} // Skip records with undefined bid/ask prices
426                _ => anyhow::bail!("Invalid data element not `QuoteTick`, was {data:?}"),
427            }
428            Ok(())
429        };
430
431        match dbn_schema {
432            dbn::Schema::Mbp1 => {
433                while let Some(msg) = decoder.decode_record::<dbn::Mbp1Msg>().await? {
434                    process_record(dbn::RecordRef::from(msg))?;
435                }
436            }
437            dbn::Schema::Tbbo => {
438                while let Some(msg) = decoder.decode_record::<dbn::TbboMsg>().await? {
439                    process_record(dbn::RecordRef::from(msg))?;
440                }
441            }
442            dbn::Schema::Cmbp1 => {
443                while let Some(msg) = decoder.decode_record::<dbn::Cmbp1Msg>().await? {
444                    process_record(dbn::RecordRef::from(msg))?;
445                }
446            }
447            dbn::Schema::Tcbbo => {
448                while let Some(msg) = decoder.decode_record::<dbn::TcbboMsg>().await? {
449                    process_record(dbn::RecordRef::from(msg))?;
450                }
451            }
452            dbn::Schema::Bbo1M => {
453                while let Some(msg) = decoder.decode_record::<dbn::Bbo1MMsg>().await? {
454                    process_record(dbn::RecordRef::from(msg))?;
455                }
456            }
457            dbn::Schema::Bbo1S => {
458                while let Some(msg) = decoder.decode_record::<dbn::Bbo1SMsg>().await? {
459                    process_record(dbn::RecordRef::from(msg))?;
460                }
461            }
462            dbn::Schema::Cbbo1S | dbn::Schema::Cbbo1M => {
463                while let Some(msg) = decoder.decode_record::<dbn::CbboMsg>().await? {
464                    process_record(dbn::RecordRef::from(msg))?;
465                }
466            }
467            _ => anyhow::bail!("Invalid schema {dbn_schema}"),
468        }
469
470        Ok(result)
471    }
472
473    /// Fetches order book depth10 snapshots for the given parameters.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the API request or data processing fails.
478    pub async fn get_range_order_book_depth10(
479        &self,
480        params: RangeQueryParams,
481        depth: Option<usize>,
482    ) -> anyhow::Result<Vec<OrderBookDepth10>> {
483        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
484        check_consistent_symbology(&symbols)?;
485
486        let first_symbol = params
487            .symbols
488            .first()
489            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
490        let stype_in = infer_symbology_type(first_symbol);
491        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
492        let time_range = get_date_time_range(params.start, end)?;
493
494        // For now, only support MBP_10 schema for depth 10
495        let _depth = depth.unwrap_or(10);
496        if _depth != 10 {
497            anyhow::bail!("Only depth=10 is currently supported for order book depths");
498        }
499
500        let range_params = GetRangeParams::builder()
501            .dataset(params.dataset)
502            .date_time_range(time_range)
503            .symbols(symbols)
504            .stype_in(stype_in)
505            .schema(dbn::Schema::Mbp10)
506            .maybe_limit(params.limit.and_then(NonZeroU64::new))
507            .build();
508
509        let price_precision_arg = params.price_precision;
510
511        let mut client = (*self.inner).clone();
512        let mut decoder = client
513            .timeseries()
514            .get_range(&range_params)
515            .await
516            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
517
518        let metadata = decoder.metadata().clone();
519        let mut metadata_cache = MetadataCache::new(metadata);
520        let mut precision_cache = AHashMap::new();
521        let mut result: Vec<OrderBookDepth10> = Vec::new();
522
523        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
524            let sym_map = self.symbol_venue_map.load();
525            let instrument_id = decode_nautilus_instrument_id(
526                &record,
527                &mut metadata_cache,
528                &self.publisher_venue_map,
529                &sym_map,
530            )?;
531            let price_precision = self.resolve_cached_price_precision(
532                &instrument_id,
533                price_precision_arg,
534                &mut precision_cache,
535            )?;
536
537            if let Some(msg) = record.get::<dbn::Mbp10Msg>() {
538                let depth = decode_mbp10_msg(msg, instrument_id, price_precision, None)?;
539                result.push(depth);
540            }
541
542            Ok(())
543        };
544
545        while let Some(msg) = decoder.decode_record::<dbn::Mbp10Msg>().await? {
546            process_record(dbn::RecordRef::from(msg))?;
547        }
548
549        Ok(result)
550    }
551
552    /// Fetches order book deltas for the given parameters.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the API request or data processing fails.
557    pub async fn get_range_order_book_deltas(
558        &self,
559        params: RangeQueryParams,
560    ) -> anyhow::Result<Vec<OrderBookDelta>> {
561        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
562        check_consistent_symbology(&symbols)?;
563
564        let first_symbol = params
565            .symbols
566            .first()
567            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
568        let stype_in = infer_symbology_type(first_symbol);
569        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
570        let time_range = get_date_time_range(params.start, end)?;
571
572        let range_params = GetRangeParams::builder()
573            .dataset(params.dataset)
574            .date_time_range(time_range)
575            .symbols(symbols)
576            .stype_in(stype_in)
577            .schema(dbn::Schema::Mbo)
578            .maybe_limit(params.limit.and_then(NonZeroU64::new))
579            .build();
580
581        let price_precision_arg = params.price_precision;
582
583        let mut client = (*self.inner).clone();
584        let mut decoder = client
585            .timeseries()
586            .get_range(&range_params)
587            .await
588            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
589
590        let metadata = decoder.metadata().clone();
591        let mut metadata_cache = MetadataCache::new(metadata);
592        let mut precision_cache = AHashMap::new();
593        let mut result: Vec<OrderBookDelta> = Vec::new();
594
595        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
596            let sym_map = self.symbol_venue_map.load();
597            let instrument_id = decode_nautilus_instrument_id(
598                &record,
599                &mut metadata_cache,
600                &self.publisher_venue_map,
601                &sym_map,
602            )?;
603            let price_precision = self.resolve_cached_price_precision(
604                &instrument_id,
605                price_precision_arg,
606                &mut precision_cache,
607            )?;
608
609            if let Some(msg) = record.get::<dbn::MboMsg>() {
610                let (delta, _trade) =
611                    decode_mbo_msg(msg, instrument_id, price_precision, None, false)?;
612
613                if let Some(delta) = delta {
614                    result.push(delta);
615                }
616            }
617
618            Ok(())
619        };
620
621        while let Some(msg) = decoder.decode_record::<dbn::MboMsg>().await? {
622            process_record(dbn::RecordRef::from(msg))?;
623        }
624
625        Ok(result)
626    }
627
628    /// Fetches trade ticks for the given parameters.
629    ///
630    /// # Errors
631    ///
632    /// Returns an error if the API request or data processing fails.
633    pub async fn get_range_trades(
634        &self,
635        params: RangeQueryParams,
636        schema: Option<String>,
637    ) -> anyhow::Result<Vec<TradeTick>> {
638        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
639        check_consistent_symbology(&symbols)?;
640
641        let first_symbol = params
642            .symbols
643            .first()
644            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
645        let stype_in = infer_symbology_type(first_symbol);
646        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
647        let time_range = get_date_time_range(params.start, end)?;
648        let schema = schema.unwrap_or_else(|| "trades".to_string());
649        let dbn_schema = dbn::Schema::from_str(&schema)?;
650
651        match dbn_schema {
652            dbn::Schema::Trades
653            | dbn::Schema::Tbbo
654            | dbn::Schema::Tcbbo
655            | dbn::Schema::Mbp1
656            | dbn::Schema::Cmbp1 => (),
657            _ => {
658                anyhow::bail!("Invalid schema. Must be one of: trades, tbbo, tcbbo, mbp-1, cmbp-1")
659            }
660        }
661
662        let range_params = GetRangeParams::builder()
663            .dataset(params.dataset)
664            .date_time_range(time_range)
665            .symbols(symbols)
666            .stype_in(stype_in)
667            .schema(dbn_schema)
668            .maybe_limit(params.limit.and_then(NonZeroU64::new))
669            .build();
670
671        let price_precision_arg = params.price_precision;
672
673        let mut client = (*self.inner).clone();
674        let mut decoder = client
675            .timeseries()
676            .get_range(&range_params)
677            .await
678            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
679
680        let metadata = decoder.metadata().clone();
681        let mut metadata_cache = MetadataCache::new(metadata);
682        let mut precision_cache = AHashMap::new();
683        let mut result: Vec<TradeTick> = Vec::new();
684
685        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
686            let sym_map = self.symbol_venue_map.load();
687            let instrument_id = decode_nautilus_instrument_id(
688                &record,
689                &mut metadata_cache,
690                &self.publisher_venue_map,
691                &sym_map,
692            )?;
693            let price_precision = self.resolve_cached_price_precision(
694                &instrument_id,
695                price_precision_arg,
696                &mut precision_cache,
697            )?;
698
699            let (data, data2) =
700                decode_record(&record, instrument_id, price_precision, None, true, true)?;
701
702            match (data, data2) {
703                (Some(Data::Trade(trade)), _) | (_, Some(Data::Trade(trade))) => result.push(trade),
704                (Some(_) | None, None) => {}
705                (None, Some(data)) => {
706                    anyhow::bail!("Invalid data element not `TradeTick`, was {data:?}")
707                }
708                (Some(data), Some(_)) => {
709                    anyhow::bail!("Invalid data element not `TradeTick`, was {data:?}")
710                }
711            }
712            Ok(())
713        };
714
715        match dbn_schema {
716            dbn::Schema::Trades => {
717                while let Some(msg) = decoder.decode_record::<dbn::TradeMsg>().await? {
718                    process_record(dbn::RecordRef::from(msg))?;
719                }
720            }
721            dbn::Schema::Mbp1 => {
722                while let Some(msg) = decoder.decode_record::<dbn::Mbp1Msg>().await? {
723                    process_record(dbn::RecordRef::from(msg))?;
724                }
725            }
726            dbn::Schema::Tbbo => {
727                while let Some(msg) = decoder.decode_record::<dbn::TbboMsg>().await? {
728                    process_record(dbn::RecordRef::from(msg))?;
729                }
730            }
731            dbn::Schema::Cmbp1 => {
732                while let Some(msg) = decoder.decode_record::<dbn::Cmbp1Msg>().await? {
733                    process_record(dbn::RecordRef::from(msg))?;
734                }
735            }
736            dbn::Schema::Tcbbo => {
737                while let Some(msg) = decoder.decode_record::<dbn::TcbboMsg>().await? {
738                    process_record(dbn::RecordRef::from(msg))?;
739                }
740            }
741            _ => anyhow::bail!("Invalid schema {dbn_schema}"),
742        }
743
744        Ok(result)
745    }
746
747    /// Fetches bars for the given parameters.
748    ///
749    /// # Errors
750    ///
751    /// Returns an error if the API request or data processing fails.
752    pub async fn get_range_bars(
753        &self,
754        params: RangeQueryParams,
755        aggregation: BarAggregation,
756        timestamp_on_close: bool,
757    ) -> anyhow::Result<Vec<Bar>> {
758        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
759        check_consistent_symbology(&symbols)?;
760
761        let first_symbol = params
762            .symbols
763            .first()
764            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
765        let stype_in = infer_symbology_type(first_symbol);
766        let schema = match aggregation {
767            BarAggregation::Second => dbn::Schema::Ohlcv1S,
768            BarAggregation::Minute => dbn::Schema::Ohlcv1M,
769            BarAggregation::Hour => dbn::Schema::Ohlcv1H,
770            BarAggregation::Day => dbn::Schema::Ohlcv1D,
771            _ => anyhow::bail!("Invalid `BarAggregation` for request, was {aggregation}"),
772        };
773
774        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
775        let time_range = get_date_time_range(params.start, end)?;
776
777        let range_params = GetRangeParams::builder()
778            .dataset(params.dataset)
779            .date_time_range(time_range)
780            .symbols(symbols)
781            .stype_in(stype_in)
782            .schema(schema)
783            .maybe_limit(params.limit.and_then(NonZeroU64::new))
784            .build();
785
786        let price_precision_arg = params.price_precision;
787
788        let mut client = (*self.inner).clone();
789        let mut decoder = client
790            .timeseries()
791            .get_range(&range_params)
792            .await
793            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
794
795        let metadata = decoder.metadata().clone();
796        let mut metadata_cache = MetadataCache::new(metadata);
797        let mut precision_cache = AHashMap::new();
798        let mut result: Vec<Bar> = Vec::new();
799
800        while let Some(msg) = decoder.decode_record::<dbn::OhlcvMsg>().await? {
801            let record = dbn::RecordRef::from(msg);
802            let sym_map = self.symbol_venue_map.load();
803            let instrument_id = decode_nautilus_instrument_id(
804                &record,
805                &mut metadata_cache,
806                &self.publisher_venue_map,
807                &sym_map,
808            )?;
809            let price_precision = self.resolve_cached_price_precision(
810                &instrument_id,
811                price_precision_arg,
812                &mut precision_cache,
813            )?;
814
815            let (data, _) = decode_record(
816                &record,
817                instrument_id,
818                price_precision,
819                None,
820                false, // Not applicable
821                timestamp_on_close,
822            )?;
823
824            match data {
825                Some(Data::Bar(bar)) => {
826                    result.push(bar);
827                }
828                _ => anyhow::bail!("Invalid data element not `Bar`, was {data:?}"),
829            }
830        }
831
832        Ok(result)
833    }
834
835    /// Fetches imbalance data for the given parameters.
836    ///
837    /// # Errors
838    ///
839    /// Returns an error if the API request or data processing fails.
840    pub async fn get_range_imbalance(
841        &self,
842        params: RangeQueryParams,
843    ) -> anyhow::Result<Vec<DatabentoImbalance>> {
844        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
845        check_consistent_symbology(&symbols)?;
846
847        let first_symbol = params
848            .symbols
849            .first()
850            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
851        let stype_in = infer_symbology_type(first_symbol);
852        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
853        let time_range = get_date_time_range(params.start, end)?;
854
855        let range_params = GetRangeParams::builder()
856            .dataset(params.dataset)
857            .date_time_range(time_range)
858            .symbols(symbols)
859            .stype_in(stype_in)
860            .schema(dbn::Schema::Imbalance)
861            .maybe_limit(params.limit.and_then(NonZeroU64::new))
862            .build();
863
864        let price_precision_arg = params.price_precision;
865
866        let mut client = (*self.inner).clone();
867        let mut decoder = client
868            .timeseries()
869            .get_range(&range_params)
870            .await
871            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
872
873        let metadata = decoder.metadata().clone();
874        let mut metadata_cache = MetadataCache::new(metadata);
875        let mut precision_cache = AHashMap::new();
876        let mut result: Vec<DatabentoImbalance> = Vec::new();
877
878        while let Some(msg) = decoder.decode_record::<dbn::ImbalanceMsg>().await? {
879            let record = dbn::RecordRef::from(msg);
880            let sym_map = self.symbol_venue_map.load();
881            let instrument_id = decode_nautilus_instrument_id(
882                &record,
883                &mut metadata_cache,
884                &self.publisher_venue_map,
885                &sym_map,
886            )?;
887            let price_precision = self.resolve_cached_price_precision(
888                &instrument_id,
889                price_precision_arg,
890                &mut precision_cache,
891            )?;
892
893            let imbalance = decode_imbalance_msg(msg, instrument_id, price_precision, None)?;
894            result.push(imbalance);
895        }
896
897        Ok(result)
898    }
899
900    /// Fetches statistics data for the given parameters.
901    ///
902    /// # Errors
903    ///
904    /// Returns an error if the API request or data processing fails.
905    pub async fn get_range_statistics(
906        &self,
907        params: RangeQueryParams,
908    ) -> anyhow::Result<Vec<DatabentoStatistics>> {
909        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
910        check_consistent_symbology(&symbols)?;
911
912        let first_symbol = params
913            .symbols
914            .first()
915            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
916        let stype_in = infer_symbology_type(first_symbol);
917        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
918        let time_range = get_date_time_range(params.start, end)?;
919
920        let range_params = GetRangeParams::builder()
921            .dataset(params.dataset)
922            .date_time_range(time_range)
923            .symbols(symbols)
924            .stype_in(stype_in)
925            .schema(dbn::Schema::Statistics)
926            .maybe_limit(params.limit.and_then(NonZeroU64::new))
927            .build();
928
929        let price_precision_arg = params.price_precision;
930
931        let mut client = (*self.inner).clone();
932        let mut decoder = client
933            .timeseries()
934            .get_range(&range_params)
935            .await
936            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
937
938        let metadata = decoder.metadata().clone();
939        let mut metadata_cache = MetadataCache::new(metadata);
940        let mut precision_cache = AHashMap::new();
941        let mut result: Vec<DatabentoStatistics> = Vec::new();
942
943        while let Some(msg) = decoder.decode_record::<dbn::StatMsg>().await? {
944            // Precheck before precision resolution so unmodeled types skip cleanly
945            if !is_supported_stat_type(msg.stat_type) {
946                log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
947                continue;
948            }
949
950            let record = dbn::RecordRef::from(msg);
951            let sym_map = self.symbol_venue_map.load();
952            let instrument_id = decode_nautilus_instrument_id(
953                &record,
954                &mut metadata_cache,
955                &self.publisher_venue_map,
956                &sym_map,
957            )?;
958            let price_precision = self.resolve_cached_price_precision(
959                &instrument_id,
960                price_precision_arg,
961                &mut precision_cache,
962            )?;
963
964            if let Some(statistics) =
965                decode_statistics_msg(msg, instrument_id, price_precision, None)?
966            {
967                result.push(statistics);
968            }
969        }
970
971        Ok(result)
972    }
973
974    /// Fetches status data for the given parameters.
975    ///
976    /// # Errors
977    ///
978    /// Returns an error if the API request or data processing fails.
979    pub async fn get_range_status(
980        &self,
981        params: RangeQueryParams,
982    ) -> anyhow::Result<Vec<InstrumentStatus>> {
983        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
984        check_consistent_symbology(&symbols)?;
985
986        let first_symbol = params
987            .symbols
988            .first()
989            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
990        let stype_in = infer_symbology_type(first_symbol);
991        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
992        let time_range = get_date_time_range(params.start, end)?;
993
994        let range_params = GetRangeParams::builder()
995            .dataset(params.dataset)
996            .date_time_range(time_range)
997            .symbols(symbols)
998            .stype_in(stype_in)
999            .schema(dbn::Schema::Status)
1000            .maybe_limit(params.limit.and_then(NonZeroU64::new))
1001            .build();
1002
1003        let mut client = (*self.inner).clone();
1004        let mut decoder = client
1005            .timeseries()
1006            .get_range(&range_params)
1007            .await
1008            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
1009
1010        let metadata = decoder.metadata().clone();
1011        let mut metadata_cache = MetadataCache::new(metadata);
1012        let mut result: Vec<InstrumentStatus> = Vec::new();
1013
1014        while let Some(msg) = decoder.decode_record::<dbn::StatusMsg>().await? {
1015            let record = dbn::RecordRef::from(msg);
1016            let sym_map = self.symbol_venue_map.load();
1017            let instrument_id = decode_nautilus_instrument_id(
1018                &record,
1019                &mut metadata_cache,
1020                &self.publisher_venue_map,
1021                &sym_map,
1022            )?;
1023
1024            let status = decode_status_msg(msg, instrument_id, None)?;
1025            result.push(status);
1026        }
1027
1028        Ok(result)
1029    }
1030
1031    /// Helper method to prepare symbols from instrument IDs.
1032    pub fn prepare_symbols_from_instrument_ids(
1033        &self,
1034        instrument_ids: &[InstrumentId],
1035    ) -> Vec<String> {
1036        self.symbol_venue_map.rcu(|m| {
1037            for id in instrument_ids {
1038                m.entry(id.symbol).or_insert(id.venue);
1039            }
1040        });
1041
1042        instrument_ids
1043            .iter()
1044            .map(|id| id.symbol.to_string())
1045            .collect()
1046    }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use nautilus_core::time::get_atomic_clock_realtime;
1052    use rstest::{fixture, rstest};
1053
1054    use super::*;
1055
1056    fn test_api_key() -> String {
1057        "test-000000000000000000000000000".to_string()
1058    }
1059
1060    fn publishers_path() -> PathBuf {
1061        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json")
1062    }
1063
1064    #[fixture]
1065    fn historical_client() -> DatabentoHistoricalClient {
1066        DatabentoHistoricalClient::new(
1067            Credential::new(test_api_key()),
1068            publishers_path(),
1069            get_atomic_clock_realtime(),
1070            false,
1071        )
1072        .unwrap()
1073    }
1074
1075    #[rstest]
1076    fn test_new_with_base_url_rejects_invalid_url() {
1077        let err = DatabentoHistoricalClient::new_with_base_url(
1078            Credential::new(test_api_key()),
1079            publishers_path(),
1080            get_atomic_clock_realtime(),
1081            false,
1082            "://invalid",
1083        )
1084        .expect_err("expected invalid base URL to fail");
1085        let err_msg = format!("{err}");
1086
1087        assert!(
1088            err_msg.contains("Failed to parse Databento Historical API base URL"),
1089            "unexpected error message: {err_msg}",
1090        );
1091    }
1092
1093    #[rstest]
1094    fn test_set_price_precision_inserts_into_cache(historical_client: DatabentoHistoricalClient) {
1095        let symbol = Symbol::from("ESM4");
1096
1097        assert_eq!(historical_client.price_precision(symbol), None);
1098
1099        historical_client.set_price_precision(symbol, 2);
1100
1101        assert_eq!(historical_client.price_precision(symbol), Some(2));
1102    }
1103
1104    #[rstest]
1105    fn test_resolve_price_precision_explicit_arg(historical_client: DatabentoHistoricalClient) {
1106        let instrument_id = InstrumentId::from("ESM4.GLBX");
1107        // Seed a deliberately wrong cache value so we know the explicit arg wins
1108        historical_client.set_price_precision(Symbol::from("ESM4"), 9);
1109
1110        let precision = historical_client
1111            .resolve_price_precision(&instrument_id, Some(2))
1112            .unwrap();
1113        assert_eq!(precision, 2);
1114    }
1115
1116    #[rstest]
1117    fn test_resolve_price_precision_cache_hit(historical_client: DatabentoHistoricalClient) {
1118        let instrument_id = InstrumentId::from("ESM4.GLBX");
1119        historical_client.set_price_precision(Symbol::from("ESM4"), 2);
1120
1121        let precision = historical_client
1122            .resolve_price_precision(&instrument_id, None)
1123            .unwrap();
1124        assert_eq!(precision, 2);
1125    }
1126
1127    #[rstest]
1128    fn test_resolve_price_precision_cache_miss_errors(
1129        historical_client: DatabentoHistoricalClient,
1130    ) {
1131        let instrument_id = InstrumentId::from("ESM4.GLBX");
1132
1133        let err = historical_client
1134            .resolve_price_precision(&instrument_id, None)
1135            .expect_err("expected cache-miss error");
1136        let err_msg = format!("{err}");
1137        assert!(
1138            err_msg.contains("Could not resolve `price_precision`"),
1139            "unexpected error message: {err_msg}",
1140        );
1141        assert!(
1142            err_msg.contains("ESM4.GLBX"),
1143            "error should name the instrument: {err_msg}",
1144        );
1145    }
1146}