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        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 depth 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_depth(
480        &self,
481        params: RangeQueryParams,
482        depth: Option<usize>,
483    ) -> anyhow::Result<Vec<OrderBookDepth>> {
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<OrderBookDepth> = 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 = ohlcv_schema_from_aggregation(aggregation)?;
774
775        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
776        let time_range = get_date_time_range(params.start, end)?;
777
778        let range_params = GetRangeParams::builder()
779            .dataset(params.dataset)
780            .date_time_range(time_range)
781            .symbols(symbols)
782            .stype_in(stype_in)
783            .schema(schema)
784            .maybe_limit(params.limit.and_then(NonZeroU64::new))
785            .build();
786
787        let price_precision_arg = params.price_precision;
788
789        let mut client = (*self.inner).clone();
790        let mut decoder = client
791            .timeseries()
792            .get_range(&range_params)
793            .await
794            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
795
796        let metadata = decoder.metadata().clone();
797        let mut metadata_cache = MetadataCache::new(metadata);
798        let mut precision_cache = AHashMap::new();
799        let mut result: Vec<Bar> = Vec::new();
800
801        while let Some(msg) = decoder.decode_record::<dbn::OhlcvMsg>().await? {
802            let record = dbn::RecordRef::from(msg);
803            let sym_map = self.symbol_venue_map.load();
804            let instrument_id = decode_nautilus_instrument_id(
805                &record,
806                &mut metadata_cache,
807                &self.publisher_venue_map,
808                &sym_map,
809            )?;
810            let price_precision = self.resolve_cached_price_precision(
811                &instrument_id,
812                price_precision_arg,
813                &mut precision_cache,
814            )?;
815
816            let (data, _) = decode_record(
817                &record,
818                instrument_id,
819                price_precision,
820                None,
821                false, // Not applicable
822                timestamp_on_close,
823            )?;
824
825            match data {
826                Some(Data::Bar(bar)) => {
827                    result.push(bar);
828                }
829                _ => anyhow::bail!("Invalid data element not `Bar`, was {data:?}"),
830            }
831        }
832
833        Ok(result)
834    }
835
836    /// Fetches imbalance data for the given parameters.
837    ///
838    /// # Errors
839    ///
840    /// Returns an error if the API request or data processing fails.
841    pub async fn get_range_imbalance(
842        &self,
843        params: RangeQueryParams,
844    ) -> anyhow::Result<Vec<DatabentoImbalance>> {
845        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
846        check_consistent_symbology(&symbols)?;
847
848        let first_symbol = params
849            .symbols
850            .first()
851            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
852        let stype_in = infer_symbology_type(first_symbol);
853        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
854        let time_range = get_date_time_range(params.start, end)?;
855
856        let range_params = GetRangeParams::builder()
857            .dataset(params.dataset)
858            .date_time_range(time_range)
859            .symbols(symbols)
860            .stype_in(stype_in)
861            .schema(dbn::Schema::Imbalance)
862            .maybe_limit(params.limit.and_then(NonZeroU64::new))
863            .build();
864
865        let price_precision_arg = params.price_precision;
866
867        let mut client = (*self.inner).clone();
868        let mut decoder = client
869            .timeseries()
870            .get_range(&range_params)
871            .await
872            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
873
874        let metadata = decoder.metadata().clone();
875        let mut metadata_cache = MetadataCache::new(metadata);
876        let mut precision_cache = AHashMap::new();
877        let mut result: Vec<DatabentoImbalance> = Vec::new();
878
879        while let Some(msg) = decoder.decode_record::<dbn::ImbalanceMsg>().await? {
880            let record = dbn::RecordRef::from(msg);
881            let sym_map = self.symbol_venue_map.load();
882            let instrument_id = decode_nautilus_instrument_id(
883                &record,
884                &mut metadata_cache,
885                &self.publisher_venue_map,
886                &sym_map,
887            )?;
888            let price_precision = self.resolve_cached_price_precision(
889                &instrument_id,
890                price_precision_arg,
891                &mut precision_cache,
892            )?;
893
894            let imbalance = decode_imbalance_msg(msg, instrument_id, price_precision, None)?;
895            result.push(imbalance);
896        }
897
898        Ok(result)
899    }
900
901    /// Fetches statistics data for the given parameters.
902    ///
903    /// # Errors
904    ///
905    /// Returns an error if the API request or data processing fails.
906    pub async fn get_range_statistics(
907        &self,
908        params: RangeQueryParams,
909    ) -> anyhow::Result<Vec<DatabentoStatistics>> {
910        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
911        check_consistent_symbology(&symbols)?;
912
913        let first_symbol = params
914            .symbols
915            .first()
916            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
917        let stype_in = infer_symbology_type(first_symbol);
918        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
919        let time_range = get_date_time_range(params.start, end)?;
920
921        let range_params = GetRangeParams::builder()
922            .dataset(params.dataset)
923            .date_time_range(time_range)
924            .symbols(symbols)
925            .stype_in(stype_in)
926            .schema(dbn::Schema::Statistics)
927            .maybe_limit(params.limit.and_then(NonZeroU64::new))
928            .build();
929
930        let price_precision_arg = params.price_precision;
931
932        let mut client = (*self.inner).clone();
933        let mut decoder = client
934            .timeseries()
935            .get_range(&range_params)
936            .await
937            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
938
939        let metadata = decoder.metadata().clone();
940        let mut metadata_cache = MetadataCache::new(metadata);
941        let mut precision_cache = AHashMap::new();
942        let mut result: Vec<DatabentoStatistics> = Vec::new();
943
944        while let Some(msg) = decoder.decode_record::<dbn::StatMsg>().await? {
945            // Precheck before precision resolution so unmodeled types skip cleanly
946            if !is_supported_stat_type(msg.stat_type) {
947                log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
948                continue;
949            }
950
951            let record = dbn::RecordRef::from(msg);
952            let sym_map = self.symbol_venue_map.load();
953            let instrument_id = decode_nautilus_instrument_id(
954                &record,
955                &mut metadata_cache,
956                &self.publisher_venue_map,
957                &sym_map,
958            )?;
959            let price_precision = self.resolve_cached_price_precision(
960                &instrument_id,
961                price_precision_arg,
962                &mut precision_cache,
963            )?;
964
965            if let Some(statistics) =
966                decode_statistics_msg(msg, instrument_id, price_precision, None)?
967            {
968                result.push(statistics);
969            }
970        }
971
972        Ok(result)
973    }
974
975    /// Fetches status data for the given parameters.
976    ///
977    /// # Errors
978    ///
979    /// Returns an error if the API request or data processing fails.
980    pub async fn get_range_status(
981        &self,
982        params: RangeQueryParams,
983    ) -> anyhow::Result<Vec<InstrumentStatus>> {
984        let symbols: Vec<&str> = params.symbols.iter().map(String::as_str).collect();
985        check_consistent_symbology(&symbols)?;
986
987        let first_symbol = params
988            .symbols
989            .first()
990            .ok_or_else(|| anyhow::anyhow!("No symbols provided"))?;
991        let stype_in = infer_symbology_type(first_symbol);
992        let end = params.end.unwrap_or_else(|| self.clock.get_time_ns());
993        let time_range = get_date_time_range(params.start, end)?;
994
995        let range_params = GetRangeParams::builder()
996            .dataset(params.dataset)
997            .date_time_range(time_range)
998            .symbols(symbols)
999            .stype_in(stype_in)
1000            .schema(dbn::Schema::Status)
1001            .maybe_limit(params.limit.and_then(NonZeroU64::new))
1002            .build();
1003
1004        let mut client = (*self.inner).clone();
1005        let mut decoder = client
1006            .timeseries()
1007            .get_range(&range_params)
1008            .await
1009            .map_err(|e| anyhow::anyhow!("Failed to get range: {e}"))?;
1010
1011        let metadata = decoder.metadata().clone();
1012        let mut metadata_cache = MetadataCache::new(metadata);
1013        let mut result: Vec<InstrumentStatus> = Vec::new();
1014
1015        while let Some(msg) = decoder.decode_record::<dbn::StatusMsg>().await? {
1016            let record = dbn::RecordRef::from(msg);
1017            let sym_map = self.symbol_venue_map.load();
1018            let instrument_id = decode_nautilus_instrument_id(
1019                &record,
1020                &mut metadata_cache,
1021                &self.publisher_venue_map,
1022                &sym_map,
1023            )?;
1024
1025            let status = decode_status_msg(msg, instrument_id, None)?;
1026            result.push(status);
1027        }
1028
1029        Ok(result)
1030    }
1031
1032    /// Prepares symbols from instrument IDs.
1033    pub fn prepare_symbols_from_instrument_ids(
1034        &self,
1035        instrument_ids: &[InstrumentId],
1036    ) -> Vec<String> {
1037        self.symbol_venue_map.rcu(|m| {
1038            for id in instrument_ids {
1039                m.entry(id.symbol).or_insert(id.venue);
1040            }
1041        });
1042
1043        instrument_ids
1044            .iter()
1045            .map(|id| id.symbol.to_string())
1046            .collect()
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use std::ffi::c_char;
1053
1054    use databento::dbn::{MappingInterval, SymbolMapping, encode::EncodeRecord};
1055    use nautilus_core::time::get_atomic_clock_realtime;
1056    use rstest::{fixture, rstest};
1057    use time::macros::date;
1058    use tokio::{
1059        io::{AsyncReadExt, AsyncWriteExt},
1060        net::TcpListener,
1061    };
1062
1063    use super::*;
1064
1065    fn test_api_key() -> String {
1066        "test-000000000000000000000000000".to_string()
1067    }
1068
1069    fn publishers_path() -> PathBuf {
1070        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("publishers.json")
1071    }
1072
1073    fn mbo_record(action: c_char, flags: u8, sequence: u32) -> dbn::MboMsg {
1074        let ts_event = 1_609_160_400_000_000_000;
1075        dbn::MboMsg {
1076            hd: dbn::RecordHeader::new::<dbn::MboMsg>(dbn::rtype::MBO, 1, 1, ts_event),
1077            order_id: u64::from(sequence),
1078            price: 4_800_250_000_000,
1079            size: 2,
1080            flags: dbn::FlagSet::new(flags),
1081            channel_id: 1,
1082            action,
1083            side: 'A' as c_char,
1084            ts_recv: ts_event,
1085            ts_in_delta: 0,
1086            sequence,
1087        }
1088    }
1089
1090    fn encode_mbo_response(records: &[dbn::MboMsg]) -> Vec<u8> {
1091        let metadata = dbn::Metadata::builder()
1092            .dataset("GLBX.MDP3")
1093            .schema(Some(dbn::Schema::Mbo))
1094            .start(1_609_160_400_000_000_000)
1095            .stype_in(Some(dbn::SType::RawSymbol))
1096            .stype_out(dbn::SType::InstrumentId)
1097            .symbols(vec!["ESM4".to_string()])
1098            .mappings(vec![SymbolMapping {
1099                raw_symbol: "ESM4".to_string(),
1100                intervals: vec![MappingInterval {
1101                    start_date: date!(2020 - 12 - 28),
1102                    end_date: date!(2020 - 12 - 29),
1103                    symbol: "1".to_string(),
1104                }],
1105            }])
1106            .build();
1107        let mut body = Vec::new();
1108        {
1109            let mut encoder = dbn::encode::dbn::Encoder::with_zstd(&mut body, &metadata).unwrap();
1110            encoder.encode_records(records).unwrap();
1111            encoder.flush().unwrap();
1112        }
1113        body
1114    }
1115
1116    async fn serve_response(listener: TcpListener, body: Vec<u8>) {
1117        let (mut stream, _) = listener.accept().await.unwrap();
1118        let mut request = Vec::new();
1119        let mut buffer = [0_u8; 1024];
1120        loop {
1121            let read = stream.read(&mut buffer).await.unwrap();
1122            if read == 0 {
1123                break;
1124            }
1125            request.extend_from_slice(&buffer[..read]);
1126            if request.windows(4).any(|window| window == b"\r\n\r\n") {
1127                break;
1128            }
1129        }
1130
1131        let headers = format!(
1132            "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1133            body.len(),
1134        );
1135        stream.write_all(headers.as_bytes()).await.unwrap();
1136        stream.write_all(&body).await.unwrap();
1137        stream.shutdown().await.unwrap();
1138    }
1139
1140    #[fixture]
1141    fn historical_client() -> DatabentoHistoricalClient {
1142        DatabentoHistoricalClient::new(
1143            Credential::new(test_api_key()),
1144            publishers_path(),
1145            get_atomic_clock_realtime(),
1146            false,
1147        )
1148        .unwrap()
1149    }
1150
1151    #[rstest]
1152    fn test_new_with_base_url_rejects_invalid_url() {
1153        let err = DatabentoHistoricalClient::new_with_base_url(
1154            Credential::new(test_api_key()),
1155            publishers_path(),
1156            get_atomic_clock_realtime(),
1157            false,
1158            "://invalid",
1159        )
1160        .expect_err("expected invalid base URL to fail");
1161        let err_msg = format!("{err}");
1162
1163        assert!(
1164            err_msg.contains("Failed to parse Databento Historical API base URL"),
1165            "unexpected error message: {err_msg}",
1166        );
1167    }
1168
1169    #[rstest]
1170    #[tokio::test]
1171    async fn test_get_range_order_book_deltas_preserves_boundaries_and_drains_end() {
1172        let last = dbn::flags::LAST;
1173        let body = encode_mbo_response(&[
1174            mbo_record('C' as c_char, 0, 1),
1175            mbo_record('N' as c_char, last, 2),
1176            mbo_record('C' as c_char, 0, 3),
1177        ]);
1178        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1179        let base_url = format!("http://{}/", listener.local_addr().unwrap());
1180        let server = tokio::spawn(serve_response(listener, body));
1181        let client = DatabentoHistoricalClient::new_with_base_url(
1182            Credential::new(test_api_key()),
1183            publishers_path(),
1184            get_atomic_clock_realtime(),
1185            false,
1186            &base_url,
1187        )
1188        .unwrap();
1189
1190        let deltas = client
1191            .get_range_order_book_deltas(RangeQueryParams {
1192                dataset: "GLBX.MDP3".to_string(),
1193                symbols: vec!["ESM4".to_string()],
1194                start: 1_609_160_000_000_000_000.into(),
1195                end: Some(1_609_161_000_000_000_000.into()),
1196                limit: None,
1197                price_precision: Some(2),
1198            })
1199            .await
1200            .unwrap();
1201        server.await.unwrap();
1202
1203        assert_eq!(deltas.len(), 2);
1204        assert_eq!(deltas[0].instrument_id, InstrumentId::from("ESM4.GLBX"));
1205        assert_eq!(deltas[0].order.order_id, 1);
1206        assert_eq!(deltas[0].flags, last);
1207        assert_eq!(deltas[0].sequence, 1);
1208        assert_eq!(deltas[1].instrument_id, InstrumentId::from("ESM4.GLBX"));
1209        assert_eq!(deltas[1].order.order_id, 3);
1210        assert_eq!(deltas[1].flags, 0);
1211        assert_eq!(deltas[1].sequence, 3);
1212    }
1213
1214    #[rstest]
1215    fn test_set_price_precision_inserts_into_cache(historical_client: DatabentoHistoricalClient) {
1216        let symbol = Symbol::from("ESM4");
1217
1218        assert_eq!(historical_client.price_precision(symbol), None);
1219
1220        historical_client.set_price_precision(symbol, 2);
1221
1222        assert_eq!(historical_client.price_precision(symbol), Some(2));
1223    }
1224
1225    #[rstest]
1226    fn test_resolve_price_precision_explicit_arg(historical_client: DatabentoHistoricalClient) {
1227        let instrument_id = InstrumentId::from("ESM4.GLBX");
1228        // Seed a deliberately wrong cache value so we know the explicit arg wins
1229        historical_client.set_price_precision(Symbol::from("ESM4"), 9);
1230
1231        let precision = historical_client
1232            .resolve_price_precision(&instrument_id, Some(2))
1233            .unwrap();
1234        assert_eq!(precision, 2);
1235    }
1236
1237    #[rstest]
1238    fn test_resolve_price_precision_cache_hit(historical_client: DatabentoHistoricalClient) {
1239        let instrument_id = InstrumentId::from("ESM4.GLBX");
1240        historical_client.set_price_precision(Symbol::from("ESM4"), 2);
1241
1242        let precision = historical_client
1243            .resolve_price_precision(&instrument_id, None)
1244            .unwrap();
1245        assert_eq!(precision, 2);
1246    }
1247
1248    #[rstest]
1249    fn test_resolve_price_precision_cache_miss_errors(
1250        historical_client: DatabentoHistoricalClient,
1251    ) {
1252        let instrument_id = InstrumentId::from("ESM4.GLBX");
1253
1254        let err = historical_client
1255            .resolve_price_precision(&instrument_id, None)
1256            .expect_err("expected cache-miss error");
1257        let err_msg = format!("{err}");
1258        assert!(
1259            err_msg.contains("Could not resolve `price_precision`"),
1260            "unexpected error message: {err_msg}",
1261        );
1262        assert!(
1263            err_msg.contains("ESM4.GLBX"),
1264            "error should name the instrument: {err_msg}",
1265        );
1266    }
1267}