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        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 when 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        for instrument in &instruments {
334            self.price_precisions
335                .insert(instrument.id().symbol, instrument.price_precision());
336        }
337
338        Ok(instruments)
339    }
340
341    /// Fetches quote ticks for the given parameters.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if the API request or data processing fails.
346    pub async fn get_range_quotes(
347        &self,
348        params: RangeQueryParams,
349        schema: Option<String>,
350    ) -> anyhow::Result<Vec<QuoteTick>> {
351        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
352        check_consistent_symbology(&symbols)?;
353
354        let first_symbol = params
355            .symbols
356            .first()
357            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
358        let stype_in = infer_symbology_type(first_symbol);
359        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
360        let time_range = get_date_time_range(params.start, end)?;
361        let schema = schema.unwrap_or_else(|| "mbp-1".to_string());
362        let dbn_schema = dbn::Schema::from_str(&schema)?;
363
364        match dbn_schema {
365            dbn::Schema::Mbp1
366            | dbn::Schema::Tbbo
367            | dbn::Schema::Bbo1S
368            | dbn::Schema::Bbo1M
369            | dbn::Schema::Cmbp1
370            | dbn::Schema::Tcbbo
371            | dbn::Schema::Cbbo1S
372            | dbn::Schema::Cbbo1M => (),
373            _ => anyhow::bail!(
374                "Invalid schema. Must be one of: mbp-1, tbbo, bbo-1s, bbo-1m, cmbp-1, tcbbo, cbbo-1s, cbbo-1m"
375            ),
376        }
377
378        let range_params = GetRangeParams::builder()
379            .dataset(params.dataset)
380            .date_time_range(time_range)
381            .symbols(symbols)
382            .stype_in(stype_in)
383            .schema(dbn_schema)
384            .maybe_limit(params.limit.and_then(NonZeroU64::new))
385            .build();
386
387        let price_precision_arg = params.price_precision;
388
389        let mut client = (*self.inner).clone();
390        let mut decoder = client
391            .timeseries()
392            .get_range(&range_params)
393            .await
394            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
395
396        let metadata = decoder.metadata().clone();
397        let mut metadata_cache = MetadataCache::new(metadata);
398        let mut precision_cache = AHashMap::new();
399        let mut result: Vec<QuoteTick> = Vec::new();
400
401        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
402            let sym_map = self.symbol_venue_map.load();
403            let instrument_id = decode_nautilus_instrument_id(
404                &record,
405                &mut metadata_cache,
406                &self.publisher_venue_map,
407                &sym_map,
408            )?;
409            let price_precision = self.resolve_cached_price_precision(
410                &instrument_id,
411                price_precision_arg,
412                &mut precision_cache,
413            )?;
414
415            let (data, _) = decode_record(
416                &record,
417                instrument_id,
418                price_precision,
419                None,
420                false, // Don't include trades
421                true,
422            )?;
423
424            match data {
425                Some(Data::Quote(quote)) => result.push(quote),
426                None => {} // Skip records with undefined bid/ask prices
427                _ => anyhow::bail!("Invalid data element not `QuoteTick`, was {data:?}"),
428            }
429            Ok(())
430        };
431
432        match dbn_schema {
433            dbn::Schema::Mbp1 => {
434                while let Some(msg) = decoder.decode_record::<dbn::Mbp1Msg>().await? {
435                    process_record(dbn::RecordRef::from(msg))?;
436                }
437            }
438            dbn::Schema::Tbbo => {
439                while let Some(msg) = decoder.decode_record::<dbn::TbboMsg>().await? {
440                    process_record(dbn::RecordRef::from(msg))?;
441                }
442            }
443            dbn::Schema::Cmbp1 => {
444                while let Some(msg) = decoder.decode_record::<dbn::Cmbp1Msg>().await? {
445                    process_record(dbn::RecordRef::from(msg))?;
446                }
447            }
448            dbn::Schema::Tcbbo => {
449                while let Some(msg) = decoder.decode_record::<dbn::TcbboMsg>().await? {
450                    process_record(dbn::RecordRef::from(msg))?;
451                }
452            }
453            dbn::Schema::Bbo1M => {
454                while let Some(msg) = decoder.decode_record::<dbn::Bbo1MMsg>().await? {
455                    process_record(dbn::RecordRef::from(msg))?;
456                }
457            }
458            dbn::Schema::Bbo1S => {
459                while let Some(msg) = decoder.decode_record::<dbn::Bbo1SMsg>().await? {
460                    process_record(dbn::RecordRef::from(msg))?;
461                }
462            }
463            dbn::Schema::Cbbo1S | dbn::Schema::Cbbo1M => {
464                while let Some(msg) = decoder.decode_record::<dbn::CbboMsg>().await? {
465                    process_record(dbn::RecordRef::from(msg))?;
466                }
467            }
468            _ => anyhow::bail!("Invalid schema {dbn_schema}"),
469        }
470
471        Ok(result)
472    }
473
474    /// Fetches order book depth10 snapshots for the given parameters.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the API request or data processing fails.
479    pub async fn get_range_order_book_depth10(
480        &self,
481        params: RangeQueryParams,
482        depth: Option<usize>,
483    ) -> anyhow::Result<Vec<OrderBookDepth10>> {
484        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
485        check_consistent_symbology(&symbols)?;
486
487        let first_symbol = params
488            .symbols
489            .first()
490            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
491        let stype_in = infer_symbology_type(first_symbol);
492        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
493        let time_range = get_date_time_range(params.start, end)?;
494
495        // For now, only support MBP_10 schema for depth 10
496        let _depth = depth.unwrap_or(10);
497        if _depth != 10 {
498            anyhow::bail!("Only depth=10 is currently supported for order book depths");
499        }
500
501        let range_params = GetRangeParams::builder()
502            .dataset(params.dataset)
503            .date_time_range(time_range)
504            .symbols(symbols)
505            .stype_in(stype_in)
506            .schema(dbn::Schema::Mbp10)
507            .maybe_limit(params.limit.and_then(NonZeroU64::new))
508            .build();
509
510        let price_precision_arg = params.price_precision;
511
512        let mut client = (*self.inner).clone();
513        let mut decoder = client
514            .timeseries()
515            .get_range(&range_params)
516            .await
517            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
518
519        let metadata = decoder.metadata().clone();
520        let mut metadata_cache = MetadataCache::new(metadata);
521        let mut precision_cache = AHashMap::new();
522        let mut result: Vec<OrderBookDepth10> = Vec::new();
523
524        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
525            let sym_map = self.symbol_venue_map.load();
526            let instrument_id = decode_nautilus_instrument_id(
527                &record,
528                &mut metadata_cache,
529                &self.publisher_venue_map,
530                &sym_map,
531            )?;
532            let price_precision = self.resolve_cached_price_precision(
533                &instrument_id,
534                price_precision_arg,
535                &mut precision_cache,
536            )?;
537
538            if let Some(msg) = record.get::<dbn::Mbp10Msg>() {
539                let depth = decode_mbp10_msg(msg, instrument_id, price_precision, None)?;
540                result.push(depth);
541            }
542
543            Ok(())
544        };
545
546        while let Some(msg) = decoder.decode_record::<dbn::Mbp10Msg>().await? {
547            process_record(dbn::RecordRef::from(msg))?;
548        }
549
550        Ok(result)
551    }
552
553    /// Fetches order book deltas for the given parameters.
554    ///
555    /// # Errors
556    ///
557    /// Returns an error if the API request or data processing fails.
558    pub async fn get_range_order_book_deltas(
559        &self,
560        params: RangeQueryParams,
561    ) -> anyhow::Result<Vec<OrderBookDelta>> {
562        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
563        check_consistent_symbology(&symbols)?;
564
565        let first_symbol = params
566            .symbols
567            .first()
568            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
569        let stype_in = infer_symbology_type(first_symbol);
570        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
571        let time_range = get_date_time_range(params.start, end)?;
572
573        let range_params = GetRangeParams::builder()
574            .dataset(params.dataset)
575            .date_time_range(time_range)
576            .symbols(symbols)
577            .stype_in(stype_in)
578            .schema(dbn::Schema::Mbo)
579            .maybe_limit(params.limit.and_then(NonZeroU64::new))
580            .build();
581
582        let price_precision_arg = params.price_precision;
583
584        let mut client = (*self.inner).clone();
585        let mut decoder = client
586            .timeseries()
587            .get_range(&range_params)
588            .await
589            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
590
591        let metadata = decoder.metadata().clone();
592        let mut metadata_cache = MetadataCache::new(metadata);
593        let mut precision_cache = AHashMap::new();
594        let mut result: Vec<OrderBookDelta> = Vec::new();
595        let mut delta_buffer = MboDeltaBuffer::default();
596
597        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
598            let sym_map = self.symbol_venue_map.load();
599            let instrument_id = decode_nautilus_instrument_id(
600                &record,
601                &mut metadata_cache,
602                &self.publisher_venue_map,
603                &sym_map,
604            )?;
605            let price_precision = self.resolve_cached_price_precision(
606                &instrument_id,
607                price_precision_arg,
608                &mut precision_cache,
609            )?;
610
611            if let Some(msg) = record.get::<dbn::MboMsg>() {
612                let (delta, _trade) =
613                    decode_mbo_msg(msg, instrument_id, price_precision, None, false)?;
614                delta_buffer.push(msg, instrument_id, delta);
615                while let Some(delta) = delta_buffer.pop_ready() {
616                    result.push(delta);
617                }
618            }
619
620            Ok(())
621        };
622
623        while let Some(msg) = decoder.decode_record::<dbn::MboMsg>().await? {
624            process_record(dbn::RecordRef::from(msg))?;
625        }
626
627        delta_buffer.finish();
628        while let Some(delta) = delta_buffer.pop_ready() {
629            result.push(delta);
630        }
631
632        Ok(result)
633    }
634
635    /// Fetches trade ticks for the given parameters.
636    ///
637    /// # Errors
638    ///
639    /// Returns an error if the API request or data processing fails.
640    pub async fn get_range_trades(
641        &self,
642        params: RangeQueryParams,
643        schema: Option<String>,
644    ) -> anyhow::Result<Vec<TradeTick>> {
645        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
646        check_consistent_symbology(&symbols)?;
647
648        let first_symbol = params
649            .symbols
650            .first()
651            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
652        let stype_in = infer_symbology_type(first_symbol);
653        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
654        let time_range = get_date_time_range(params.start, end)?;
655        let schema = schema.unwrap_or_else(|| "trades".to_string());
656        let dbn_schema = dbn::Schema::from_str(&schema)?;
657
658        match dbn_schema {
659            dbn::Schema::Trades
660            | dbn::Schema::Tbbo
661            | dbn::Schema::Tcbbo
662            | dbn::Schema::Mbp1
663            | dbn::Schema::Cmbp1 => (),
664            _ => {
665                anyhow::bail!("Invalid schema. Must be one of: trades, tbbo, tcbbo, mbp-1, cmbp-1")
666            }
667        }
668
669        let range_params = GetRangeParams::builder()
670            .dataset(params.dataset)
671            .date_time_range(time_range)
672            .symbols(symbols)
673            .stype_in(stype_in)
674            .schema(dbn_schema)
675            .maybe_limit(params.limit.and_then(NonZeroU64::new))
676            .build();
677
678        let price_precision_arg = params.price_precision;
679
680        let mut client = (*self.inner).clone();
681        let mut decoder = client
682            .timeseries()
683            .get_range(&range_params)
684            .await
685            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
686
687        let metadata = decoder.metadata().clone();
688        let mut metadata_cache = MetadataCache::new(metadata);
689        let mut precision_cache = AHashMap::new();
690        let mut result: Vec<TradeTick> = Vec::new();
691
692        let mut process_record = |record: dbn::RecordRef| -> anyhow::Result<()> {
693            let sym_map = self.symbol_venue_map.load();
694            let instrument_id = decode_nautilus_instrument_id(
695                &record,
696                &mut metadata_cache,
697                &self.publisher_venue_map,
698                &sym_map,
699            )?;
700            let price_precision = self.resolve_cached_price_precision(
701                &instrument_id,
702                price_precision_arg,
703                &mut precision_cache,
704            )?;
705
706            let (data, data2) =
707                decode_record(&record, instrument_id, price_precision, None, true, true)?;
708
709            match (data, data2) {
710                (Some(Data::Trade(trade)), _) | (_, Some(Data::Trade(trade))) => result.push(trade),
711                (Some(_) | None, None) => {}
712                (None, Some(data)) => {
713                    anyhow::bail!("Invalid data element not `TradeTick`, was {data:?}")
714                }
715                (Some(data), Some(_)) => {
716                    anyhow::bail!("Invalid data element not `TradeTick`, was {data:?}")
717                }
718            }
719            Ok(())
720        };
721
722        match dbn_schema {
723            dbn::Schema::Trades => {
724                while let Some(msg) = decoder.decode_record::<dbn::TradeMsg>().await? {
725                    process_record(dbn::RecordRef::from(msg))?;
726                }
727            }
728            dbn::Schema::Mbp1 => {
729                while let Some(msg) = decoder.decode_record::<dbn::Mbp1Msg>().await? {
730                    process_record(dbn::RecordRef::from(msg))?;
731                }
732            }
733            dbn::Schema::Tbbo => {
734                while let Some(msg) = decoder.decode_record::<dbn::TbboMsg>().await? {
735                    process_record(dbn::RecordRef::from(msg))?;
736                }
737            }
738            dbn::Schema::Cmbp1 => {
739                while let Some(msg) = decoder.decode_record::<dbn::Cmbp1Msg>().await? {
740                    process_record(dbn::RecordRef::from(msg))?;
741                }
742            }
743            dbn::Schema::Tcbbo => {
744                while let Some(msg) = decoder.decode_record::<dbn::TcbboMsg>().await? {
745                    process_record(dbn::RecordRef::from(msg))?;
746                }
747            }
748            _ => anyhow::bail!("Invalid schema {dbn_schema}"),
749        }
750
751        Ok(result)
752    }
753
754    /// Fetches bars for the given parameters.
755    ///
756    /// # Errors
757    ///
758    /// Returns an error if the API request or data processing fails.
759    pub async fn get_range_bars(
760        &self,
761        params: RangeQueryParams,
762        aggregation: BarAggregation,
763        timestamp_on_close: bool,
764    ) -> anyhow::Result<Vec<Bar>> {
765        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
766        check_consistent_symbology(&symbols)?;
767
768        let first_symbol = params
769            .symbols
770            .first()
771            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
772        let stype_in = infer_symbology_type(first_symbol);
773        let schema = match aggregation {
774            BarAggregation::Second => dbn::Schema::Ohlcv1S,
775            BarAggregation::Minute => dbn::Schema::Ohlcv1M,
776            BarAggregation::Hour => dbn::Schema::Ohlcv1H,
777            BarAggregation::Day => dbn::Schema::Ohlcv1D,
778            _ => anyhow::bail!("Invalid `BarAggregation` for request, was {aggregation}"),
779        };
780
781        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
782        let time_range = get_date_time_range(params.start, end)?;
783
784        let range_params = GetRangeParams::builder()
785            .dataset(params.dataset)
786            .date_time_range(time_range)
787            .symbols(symbols)
788            .stype_in(stype_in)
789            .schema(schema)
790            .maybe_limit(params.limit.and_then(NonZeroU64::new))
791            .build();
792
793        let price_precision_arg = params.price_precision;
794
795        let mut client = (*self.inner).clone();
796        let mut decoder = client
797            .timeseries()
798            .get_range(&range_params)
799            .await
800            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
801
802        let metadata = decoder.metadata().clone();
803        let mut metadata_cache = MetadataCache::new(metadata);
804        let mut precision_cache = AHashMap::new();
805        let mut result: Vec<Bar> = Vec::new();
806
807        while let Some(msg) = decoder.decode_record::<dbn::OhlcvMsg>().await? {
808            let record = dbn::RecordRef::from(msg);
809            let sym_map = self.symbol_venue_map.load();
810            let instrument_id = decode_nautilus_instrument_id(
811                &record,
812                &mut metadata_cache,
813                &self.publisher_venue_map,
814                &sym_map,
815            )?;
816            let price_precision = self.resolve_cached_price_precision(
817                &instrument_id,
818                price_precision_arg,
819                &mut precision_cache,
820            )?;
821
822            let (data, _) = decode_record(
823                &record,
824                instrument_id,
825                price_precision,
826                None,
827                false, // Not applicable
828                timestamp_on_close,
829            )?;
830
831            match data {
832                Some(Data::Bar(bar)) => {
833                    result.push(bar);
834                }
835                _ => anyhow::bail!("Invalid data element not `Bar`, was {data:?}"),
836            }
837        }
838
839        Ok(result)
840    }
841
842    /// Fetches imbalance data for the given parameters.
843    ///
844    /// # Errors
845    ///
846    /// Returns an error if the API request or data processing fails.
847    pub async fn get_range_imbalance(
848        &self,
849        params: RangeQueryParams,
850    ) -> anyhow::Result<Vec<DatabentoImbalance>> {
851        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
852        check_consistent_symbology(&symbols)?;
853
854        let first_symbol = params
855            .symbols
856            .first()
857            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
858        let stype_in = infer_symbology_type(first_symbol);
859        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
860        let time_range = get_date_time_range(params.start, end)?;
861
862        let range_params = GetRangeParams::builder()
863            .dataset(params.dataset)
864            .date_time_range(time_range)
865            .symbols(symbols)
866            .stype_in(stype_in)
867            .schema(dbn::Schema::Imbalance)
868            .maybe_limit(params.limit.and_then(NonZeroU64::new))
869            .build();
870
871        let price_precision_arg = params.price_precision;
872
873        let mut client = (*self.inner).clone();
874        let mut decoder = client
875            .timeseries()
876            .get_range(&range_params)
877            .await
878            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
879
880        let metadata = decoder.metadata().clone();
881        let mut metadata_cache = MetadataCache::new(metadata);
882        let mut precision_cache = AHashMap::new();
883        let mut result: Vec<DatabentoImbalance> = Vec::new();
884
885        while let Some(msg) = decoder.decode_record::<dbn::ImbalanceMsg>().await? {
886            let record = dbn::RecordRef::from(msg);
887            let sym_map = self.symbol_venue_map.load();
888            let instrument_id = decode_nautilus_instrument_id(
889                &record,
890                &mut metadata_cache,
891                &self.publisher_venue_map,
892                &sym_map,
893            )?;
894            let price_precision = self.resolve_cached_price_precision(
895                &instrument_id,
896                price_precision_arg,
897                &mut precision_cache,
898            )?;
899
900            let imbalance = decode_imbalance_msg(msg, instrument_id, price_precision, None)?;
901            result.push(imbalance);
902        }
903
904        Ok(result)
905    }
906
907    /// Fetches statistics data for the given parameters.
908    ///
909    /// # Errors
910    ///
911    /// Returns an error if the API request or data processing fails.
912    pub async fn get_range_statistics(
913        &self,
914        params: RangeQueryParams,
915    ) -> anyhow::Result<Vec<DatabentoStatistics>> {
916        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
917        check_consistent_symbology(&symbols)?;
918
919        let first_symbol = params
920            .symbols
921            .first()
922            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
923        let stype_in = infer_symbology_type(first_symbol);
924        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
925        let time_range = get_date_time_range(params.start, end)?;
926
927        let range_params = GetRangeParams::builder()
928            .dataset(params.dataset)
929            .date_time_range(time_range)
930            .symbols(symbols)
931            .stype_in(stype_in)
932            .schema(dbn::Schema::Statistics)
933            .maybe_limit(params.limit.and_then(NonZeroU64::new))
934            .build();
935
936        let price_precision_arg = params.price_precision;
937
938        let mut client = (*self.inner).clone();
939        let mut decoder = client
940            .timeseries()
941            .get_range(&range_params)
942            .await
943            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
944
945        let metadata = decoder.metadata().clone();
946        let mut metadata_cache = MetadataCache::new(metadata);
947        let mut precision_cache = AHashMap::new();
948        let mut result: Vec<DatabentoStatistics> = Vec::new();
949
950        while let Some(msg) = decoder.decode_record::<dbn::StatMsg>().await? {
951            // Precheck before precision resolution so unmodeled types skip cleanly
952            if !is_supported_stat_type(msg.stat_type) {
953                log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
954                continue;
955            }
956
957            let record = dbn::RecordRef::from(msg);
958            let sym_map = self.symbol_venue_map.load();
959            let instrument_id = decode_nautilus_instrument_id(
960                &record,
961                &mut metadata_cache,
962                &self.publisher_venue_map,
963                &sym_map,
964            )?;
965            let price_precision = self.resolve_cached_price_precision(
966                &instrument_id,
967                price_precision_arg,
968                &mut precision_cache,
969            )?;
970
971            if let Some(statistics) =
972                decode_statistics_msg(msg, instrument_id, price_precision, None)?
973            {
974                result.push(statistics);
975            }
976        }
977
978        Ok(result)
979    }
980
981    /// Fetches status data for the given parameters.
982    ///
983    /// # Errors
984    ///
985    /// Returns an error if the API request or data processing fails.
986    pub async fn get_range_status(
987        &self,
988        params: RangeQueryParams,
989    ) -> anyhow::Result<Vec<InstrumentStatus>> {
990        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
991        check_consistent_symbology(&symbols)?;
992
993        let first_symbol = params
994            .symbols
995            .first()
996            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
997        let stype_in = infer_symbology_type(first_symbol);
998        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
999        let time_range = get_date_time_range(params.start, end)?;
1000
1001        let range_params = GetRangeParams::builder()
1002            .dataset(params.dataset)
1003            .date_time_range(time_range)
1004            .symbols(symbols)
1005            .stype_in(stype_in)
1006            .schema(dbn::Schema::Status)
1007            .maybe_limit(params.limit.and_then(NonZeroU64::new))
1008            .build();
1009
1010        let mut client = (*self.inner).clone();
1011        let mut decoder = client
1012            .timeseries()
1013            .get_range(&range_params)
1014            .await
1015            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
1016
1017        let metadata = decoder.metadata().clone();
1018        let mut metadata_cache = MetadataCache::new(metadata);
1019        let mut result: Vec<InstrumentStatus> = Vec::new();
1020
1021        while let Some(msg) = decoder.decode_record::<dbn::StatusMsg>().await? {
1022            let record = dbn::RecordRef::from(msg);
1023            let sym_map = self.symbol_venue_map.load();
1024            let instrument_id = decode_nautilus_instrument_id(
1025                &record,
1026                &mut metadata_cache,
1027                &self.publisher_venue_map,
1028                &sym_map,
1029            )?;
1030
1031            let status = decode_status_msg(msg, instrument_id, None)?;
1032            result.push(status);
1033        }
1034
1035        Ok(result)
1036    }
1037
1038    /// Helper method to prepare symbols from instrument IDs.
1039    pub fn prepare_symbols_from_instrument_ids(
1040        &self,
1041        instrument_ids: &[InstrumentId],
1042    ) -> Vec<String> {
1043        self.symbol_venue_map.rcu(|m| {
1044            for id in instrument_ids {
1045                m.entry(id.symbol).or_insert(id.venue);
1046            }
1047        });
1048
1049        instrument_ids
1050            .iter()
1051            .map(|id| id.symbol.to_string())
1052            .collect()
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use std::ffi::c_char;
1059
1060    use databento::dbn::{MappingInterval, SymbolMapping, encode::EncodeRecord};
1061    use nautilus_core::time::get_atomic_clock_realtime;
1062    use rstest::{fixture, rstest};
1063    use time::macros::date;
1064    use tokio::{
1065        io::{AsyncReadExt, AsyncWriteExt},
1066        net::TcpListener,
1067    };
1068
1069    use super::*;
1070
1071    fn test_api_key() -> String {
1072        "test-000000000000000000000000000".to_string()
1073    }
1074
1075    fn publishers_path() -> PathBuf {
1076        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json")
1077    }
1078
1079    fn mbo_record(action: c_char, flags: u8, sequence: u32) -> dbn::MboMsg {
1080        let ts_event = 1_609_160_400_000_000_000;
1081        dbn::MboMsg {
1082            hd: dbn::RecordHeader::new::<dbn::MboMsg>(dbn::rtype::MBO, 1, 1, ts_event),
1083            order_id: u64::from(sequence),
1084            price: 4_800_250_000_000,
1085            size: 2,
1086            flags: dbn::FlagSet::new(flags),
1087            channel_id: 1,
1088            action,
1089            side: 'A' as c_char,
1090            ts_recv: ts_event,
1091            ts_in_delta: 0,
1092            sequence,
1093        }
1094    }
1095
1096    fn encode_mbo_response(records: &[dbn::MboMsg]) -> Vec<u8> {
1097        let metadata = dbn::Metadata::builder()
1098            .dataset("GLBX.MDP3")
1099            .schema(Some(dbn::Schema::Mbo))
1100            .start(1_609_160_400_000_000_000)
1101            .stype_in(Some(dbn::SType::RawSymbol))
1102            .stype_out(dbn::SType::InstrumentId)
1103            .symbols(vec!["ESM4".to_string()])
1104            .mappings(vec![SymbolMapping {
1105                raw_symbol: "ESM4".to_string(),
1106                intervals: vec![MappingInterval {
1107                    start_date: date!(2020 - 12 - 28),
1108                    end_date: date!(2020 - 12 - 29),
1109                    symbol: "1".to_string(),
1110                }],
1111            }])
1112            .build();
1113        let mut body = Vec::new();
1114        {
1115            let mut encoder = dbn::encode::dbn::Encoder::with_zstd(&mut body, &metadata).unwrap();
1116            encoder.encode_records(records).unwrap();
1117            encoder.flush().unwrap();
1118        }
1119        body
1120    }
1121
1122    async fn serve_response(listener: TcpListener, body: Vec<u8>) {
1123        let (mut stream, _) = listener.accept().await.unwrap();
1124        let mut request = Vec::new();
1125        let mut buffer = [0_u8; 1024];
1126        loop {
1127            let read = stream.read(&mut buffer).await.unwrap();
1128            if read == 0 {
1129                break;
1130            }
1131            request.extend_from_slice(&buffer[..read]);
1132            if request.windows(4).any(|window| window == b"\r\n\r\n") {
1133                break;
1134            }
1135        }
1136
1137        let headers = format!(
1138            "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1139            body.len(),
1140        );
1141        stream.write_all(headers.as_bytes()).await.unwrap();
1142        stream.write_all(&body).await.unwrap();
1143        stream.shutdown().await.unwrap();
1144    }
1145
1146    #[fixture]
1147    fn historical_client() -> DatabentoHistoricalClient {
1148        DatabentoHistoricalClient::new(
1149            Credential::new(test_api_key()),
1150            publishers_path(),
1151            get_atomic_clock_realtime(),
1152            false,
1153        )
1154        .unwrap()
1155    }
1156
1157    #[rstest]
1158    fn test_new_with_base_url_rejects_invalid_url() {
1159        let err = DatabentoHistoricalClient::new_with_base_url(
1160            Credential::new(test_api_key()),
1161            publishers_path(),
1162            get_atomic_clock_realtime(),
1163            false,
1164            "://invalid",
1165        )
1166        .expect_err("expected invalid base URL to fail");
1167        let err_msg = format!("{err}");
1168
1169        assert!(
1170            err_msg.contains("Failed to parse Databento Historical API base URL"),
1171            "unexpected error message: {err_msg}",
1172        );
1173    }
1174
1175    #[rstest]
1176    #[tokio::test]
1177    async fn test_get_range_order_book_deltas_preserves_boundaries_and_drains_end() {
1178        let last = dbn::flags::LAST;
1179        let body = encode_mbo_response(&[
1180            mbo_record('C' as c_char, 0, 1),
1181            mbo_record('N' as c_char, last, 2),
1182            mbo_record('C' as c_char, 0, 3),
1183        ]);
1184        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1185        let base_url = format!("http://{}/", listener.local_addr().unwrap());
1186        let server = tokio::spawn(serve_response(listener, body));
1187        let client = DatabentoHistoricalClient::new_with_base_url(
1188            Credential::new(test_api_key()),
1189            publishers_path(),
1190            get_atomic_clock_realtime(),
1191            false,
1192            &base_url,
1193        )
1194        .unwrap();
1195
1196        let deltas = client
1197            .get_range_order_book_deltas(RangeQueryParams {
1198                dataset: "GLBX.MDP3".to_string(),
1199                symbols: vec!["ESM4".to_string()],
1200                start: 1_609_160_000_000_000_000.into(),
1201                end: Some(1_609_161_000_000_000_000.into()),
1202                limit: None,
1203                price_precision: Some(2),
1204            })
1205            .await
1206            .unwrap();
1207        server.await.unwrap();
1208
1209        assert_eq!(deltas.len(), 2);
1210        assert_eq!(deltas[0].instrument_id, InstrumentId::from("ESM4.GLBX"));
1211        assert_eq!(deltas[0].order.order_id, 1);
1212        assert_eq!(deltas[0].flags, last);
1213        assert_eq!(deltas[0].sequence, 1);
1214        assert_eq!(deltas[1].instrument_id, InstrumentId::from("ESM4.GLBX"));
1215        assert_eq!(deltas[1].order.order_id, 3);
1216        assert_eq!(deltas[1].flags, 0);
1217        assert_eq!(deltas[1].sequence, 3);
1218    }
1219
1220    #[rstest]
1221    fn test_set_price_precision_inserts_into_cache(historical_client: DatabentoHistoricalClient) {
1222        let symbol = Symbol::from("ESM4");
1223
1224        assert_eq!(historical_client.price_precision(symbol), None);
1225
1226        historical_client.set_price_precision(symbol, 2);
1227
1228        assert_eq!(historical_client.price_precision(symbol), Some(2));
1229    }
1230
1231    #[rstest]
1232    fn test_resolve_price_precision_explicit_arg(historical_client: DatabentoHistoricalClient) {
1233        let instrument_id = InstrumentId::from("ESM4.GLBX");
1234        // Seed a deliberately wrong cache value so we know the explicit arg wins
1235        historical_client.set_price_precision(Symbol::from("ESM4"), 9);
1236
1237        let precision = historical_client
1238            .resolve_price_precision(&instrument_id, Some(2))
1239            .unwrap();
1240        assert_eq!(precision, 2);
1241    }
1242
1243    #[rstest]
1244    fn test_resolve_price_precision_cache_hit(historical_client: DatabentoHistoricalClient) {
1245        let instrument_id = InstrumentId::from("ESM4.GLBX");
1246        historical_client.set_price_precision(Symbol::from("ESM4"), 2);
1247
1248        let precision = historical_client
1249            .resolve_price_precision(&instrument_id, None)
1250            .unwrap();
1251        assert_eq!(precision, 2);
1252    }
1253
1254    #[rstest]
1255    fn test_resolve_price_precision_cache_miss_errors(
1256        historical_client: DatabentoHistoricalClient,
1257    ) {
1258        let instrument_id = InstrumentId::from("ESM4.GLBX");
1259
1260        let err = historical_client
1261            .resolve_price_precision(&instrument_id, None)
1262            .expect_err("expected cache-miss error");
1263        let err_msg = format!("{err}");
1264        assert!(
1265            err_msg.contains("Could not resolve `price_precision`"),
1266            "unexpected error message: {err_msg}",
1267        );
1268        assert!(
1269            err_msg.contains("ESM4.GLBX"),
1270            "error should name the instrument: {err_msg}",
1271        );
1272    }
1273}