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