Skip to main content

nautilus_databento/
loader.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
16use std::{
17    env,
18    path::{Path, PathBuf},
19};
20
21use ahash::AHashMap;
22use anyhow::Context;
23use databento::dbn::{self, InstrumentDefMsg};
24use dbn::{
25    Publisher,
26    decode::{DbnMetadata, DecodeStream, dbn::Decoder},
27};
28use fallible_streaming_iterator::FallibleStreamingIterator;
29use indexmap::IndexMap;
30use nautilus_model::{
31    data::{Bar, Data, InstrumentStatus, OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick},
32    identifiers::{InstrumentId, Symbol, Venue},
33    instruments::{Instrument, InstrumentAny},
34};
35
36use super::{
37    decode::{
38        MboDeltaBuffer, decode_imbalance_msg, decode_mbo_msg, decode_record, decode_statistics_msg,
39        decode_status_msg, is_supported_stat_type,
40    },
41    symbology::decode_nautilus_instrument_id,
42    types::{DatabentoImbalance, DatabentoPublisher, DatabentoStatistics, Dataset, PublisherId},
43};
44use crate::{
45    common::{build_publisher_venue_map, load_publishers},
46    decode::{DatabentoDecodeConfig, decode_instrument_def_msg},
47    symbology::MetadataCache,
48};
49
50/// A Nautilus data loader for Databento Binary Encoding (DBN) format data.
51///
52/// # Supported Schemas
53///  - `MBO` -> `OrderBookDelta`
54///  - `MBP_1` -> `(QuoteTick, Option<TradeTick>)`
55///  - `MBP_10` -> `OrderBookDepth10`
56///  - `BBO_1S` -> `QuoteTick`
57///  - `BBO_1M` -> `QuoteTick`
58///  - `CMBP_1` -> `(QuoteTick, Option<TradeTick>)`
59///  - `CBBO_1S` -> `QuoteTick`
60///  - `CBBO_1M` -> `QuoteTick`
61///  - `TCBBO` -> `(QuoteTick, TradeTick)`
62///  - `TBBO` -> `(QuoteTick, TradeTick)`
63///  - `TRADES` -> `TradeTick`
64///  - `OHLCV_1S` -> `Bar`
65///  - `OHLCV_1M` -> `Bar`
66///  - `OHLCV_1H` -> `Bar`
67///  - `OHLCV_1D` -> `Bar`
68///  - `OHLCV_EOD` -> `Bar`
69///  - `DEFINITION` -> `Instrument`
70///  - `IMBALANCE` -> `DatabentoImbalance`
71///  - `STATISTICS` -> `DatabentoStatistics`
72///  - `STATUS` -> `InstrumentStatus`
73///
74/// # References
75///
76/// <https://databento.com/docs/schemas-and-data-formats>
77#[cfg_attr(
78    feature = "python",
79    pyo3::pyclass(module = "nautilus_trader.adapters.databento")
80)]
81#[cfg_attr(
82    feature = "python",
83    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.databento")
84)]
85#[derive(Debug)]
86pub struct DatabentoDataLoader {
87    publishers_map: IndexMap<PublisherId, DatabentoPublisher>,
88    venue_dataset_map: IndexMap<Venue, Dataset>,
89    publisher_venue_map: IndexMap<PublisherId, Venue>,
90    symbol_venue_map: AHashMap<Symbol, Venue>,
91    price_precisions: AHashMap<Symbol, u8>,
92}
93
94impl DatabentoDataLoader {
95    /// Creates a new [`DatabentoDataLoader`] instance.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if locating or loading publishers data fails.
100    pub fn new(publishers_filepath: Option<PathBuf>) -> anyhow::Result<Self> {
101        let mut loader = Self {
102            publishers_map: IndexMap::new(),
103            venue_dataset_map: IndexMap::new(),
104            publisher_venue_map: IndexMap::new(),
105            symbol_venue_map: AHashMap::new(),
106            price_precisions: AHashMap::new(),
107        };
108
109        // Load publishers
110        let publishers_filepath = if let Some(p) = publishers_filepath {
111            p
112        } else {
113            // Use built-in publishers path
114            let mut exe_path = env::current_exe()?;
115            exe_path.pop();
116            exe_path.push("publishers.json");
117            exe_path
118        };
119
120        loader
121            .load_publishers(publishers_filepath)
122            .context("error loading publishers.json")?;
123
124        Ok(loader)
125    }
126
127    /// Load the publishers data from the file at the given `filepath`.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the file cannot be read or parsed as JSON.
132    pub fn load_publishers(&mut self, filepath: PathBuf) -> anyhow::Result<()> {
133        let publishers = load_publishers(filepath)?;
134
135        self.publishers_map = publishers
136            .iter()
137            .cloned()
138            .map(|p| (p.publisher_id, p))
139            .collect();
140
141        let mut venue_dataset_map = IndexMap::new();
142
143        // Only insert a dataset if the venue key is not already in the map
144        for publisher in &publishers {
145            let venue = Venue::from(publisher.venue.as_str());
146            let dataset = Dataset::from(publisher.dataset.as_str());
147            venue_dataset_map.entry(venue).or_insert(dataset);
148        }
149
150        self.venue_dataset_map = venue_dataset_map;
151        apply_default_venue_dataset_mappings(&mut self.venue_dataset_map);
152
153        self.publisher_venue_map = build_publisher_venue_map(&publishers);
154
155        Ok(())
156    }
157
158    /// Returns the internal Databento publishers currently held by the loader.
159    #[must_use]
160    pub const fn get_publishers(&self) -> &IndexMap<u16, DatabentoPublisher> {
161        &self.publishers_map
162    }
163
164    /// Sets the `venue` to map to the given `dataset`.
165    pub fn set_dataset_for_venue(&mut self, dataset: Dataset, venue: Venue) {
166        _ = self.venue_dataset_map.insert(venue, dataset);
167    }
168
169    /// Returns the dataset which matches the given `venue` (if found).
170    #[must_use]
171    pub fn get_dataset_for_venue(&self, venue: &Venue) -> Option<&Dataset> {
172        self.venue_dataset_map.get(venue)
173    }
174
175    /// Returns the venue which matches the given `publisher_id` (if found).
176    #[must_use]
177    pub fn get_venue_for_publisher(&self, publisher_id: PublisherId) -> Option<&Venue> {
178        self.publisher_venue_map.get(&publisher_id)
179    }
180
181    /// Caches a `price_precision` for the given `symbol`.
182    ///
183    /// When market data is read without an explicit `price_precision` argument,
184    /// the loader resolves precision per record from this cache. Definitions
185    /// loaded via [`Self::load_instruments`] are inserted automatically.
186    pub fn set_price_precision(&mut self, symbol: Symbol, price_precision: u8) {
187        self.price_precisions.insert(symbol, price_precision);
188    }
189
190    /// Returns the cached price precisions keyed by symbol.
191    #[must_use]
192    pub const fn get_price_precisions(&self) -> &AHashMap<Symbol, u8> {
193        &self.price_precisions
194    }
195
196    /// Resolves a price precision for the given `instrument_id`.
197    ///
198    /// Resolution order:
199    /// 1. The explicit `price_precision` argument (if `Some`).
200    /// 2. The cached precision for the instrument's symbol.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error when no precision is available.
205    fn resolve_price_precision(
206        &self,
207        instrument_id: &InstrumentId,
208        price_precision: Option<u8>,
209    ) -> anyhow::Result<u8> {
210        if let Some(precision) = price_precision {
211            return Ok(precision);
212        }
213
214        self.price_precisions
215            .get(&instrument_id.symbol)
216            .copied()
217            .ok_or_else(|| {
218                anyhow::anyhow!(
219                    "Could not resolve `price_precision` for {instrument_id}: \
220                     pass `price_precision` explicitly, call `set_price_precision`, \
221                     or load the instrument definitions first via `load_instruments`"
222                )
223            })
224    }
225
226    /// Returns the schema for the given `filepath`.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the file cannot be decoded or metadata retrieval fails.
231    pub fn schema_from_file(&self, filepath: &Path) -> anyhow::Result<Option<String>> {
232        let decoder = Decoder::from_zstd_file(filepath)?;
233        let metadata = decoder.metadata();
234        Ok(metadata.schema.map(|schema| schema.to_string()))
235    }
236
237    /// Reads instrument definition records from a DBN file.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if decoding the definition records fails.
242    pub fn read_definition_records<'a>(
243        &'a mut self,
244        filepath: &Path,
245        use_exchange_as_venue: bool,
246        decode_config: Option<&'a DatabentoDecodeConfig>,
247    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<InstrumentAny>> + 'a> {
248        let decoder = Decoder::from_zstd_file(filepath)?;
249        let mut dbn_stream = decoder.decode_stream::<InstrumentDefMsg>();
250
251        // Loop over skipped records (Ok(None)) so one unsupported class does not
252        // terminate the stream
253        Ok(std::iter::from_fn(move || {
254            loop {
255                let advance = dbn_stream
256                    .advance()
257                    .map_err(|e| anyhow::anyhow!("Stream advance error: {e}"));
258                if let Err(e) = advance {
259                    return Some(Err(e));
260                }
261
262                let rec = dbn_stream.get()?;
263
264                let result: anyhow::Result<Option<InstrumentAny>> = (|| {
265                    let record = dbn::RecordRef::from(rec);
266                    let msg = record
267                        .get::<InstrumentDefMsg>()
268                        .ok_or_else(|| anyhow::anyhow!("Failed to decode InstrumentDefMsg"))?;
269
270                    let raw_symbol = rec
271                        .raw_symbol()
272                        .map_err(|e| anyhow::anyhow!("Error decoding `raw_symbol`: {e}"))?;
273                    let symbol = Symbol::from(raw_symbol);
274
275                    let publisher = rec
276                        .hd
277                        .publisher()
278                        .map_err(|e| anyhow::anyhow!("Invalid `publisher` for record: {e}"))?;
279                    let venue = match publisher {
280                        Publisher::GlbxMdp3Glbx if use_exchange_as_venue => {
281                            let exchange = rec.exchange().map_err(|e| {
282                                anyhow::anyhow!("Missing `exchange` for record: {e}")
283                            })?;
284                            let venue = Venue::from_code(exchange).map_err(|e| {
285                                anyhow::anyhow!("Venue not found for exchange {exchange}: {e}")
286                            })?;
287                            self.symbol_venue_map.insert(symbol, venue);
288                            venue
289                        }
290                        _ => *self
291                            .publisher_venue_map
292                            .get(&msg.hd.publisher_id)
293                            .ok_or_else(|| {
294                                anyhow::anyhow!(
295                                    "Venue not found for publisher_id {}",
296                                    msg.hd.publisher_id
297                                )
298                            })?,
299                    };
300                    let instrument_id = InstrumentId::new(symbol, venue);
301                    let ts_init = msg.ts_recv.into();
302
303                    decode_instrument_def_msg(rec, instrument_id, Some(ts_init), decode_config)
304                })();
305
306                match result {
307                    Ok(Some(item)) => return Some(Ok(item)),
308                    Ok(None) => {}
309                    Err(e) => return Some(Err(e)),
310                }
311            }
312        }))
313    }
314
315    /// Reads and decodes market data records from a DBN file.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if reading records fails.
320    pub fn read_records<T>(
321        &self,
322        filepath: &Path,
323        instrument_id: Option<InstrumentId>,
324        price_precision: Option<u8>,
325        include_trades: bool,
326        bars_timestamp_on_close: Option<bool>,
327    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<(Option<Data>, Option<Data>)>> + '_>
328    where
329        T: dbn::Record + dbn::HasRType + 'static,
330    {
331        let decoder = Decoder::from_zstd_file(filepath)?;
332        let mut metadata_cache = if instrument_id.is_none() {
333            Some(MetadataCache::new(decoder.metadata().clone()))
334        } else {
335            None
336        };
337        let mut dbn_stream = decoder.decode_stream::<T>();
338        let fixed_instrument_id = instrument_id.is_some();
339        let mut fixed_price_precision = price_precision;
340
341        Ok(std::iter::from_fn(move || {
342            let result: anyhow::Result<Option<(Option<Data>, Option<Data>)>> = (|| {
343                dbn_stream
344                    .advance()
345                    .map_err(|e| anyhow::anyhow!("Stream advance error: {e}"))?;
346
347                if let Some(rec) = dbn_stream.get() {
348                    let record = dbn::RecordRef::from(rec);
349                    let instrument_id = self
350                        .resolve_record_instrument_id(&record, instrument_id, &mut metadata_cache)
351                        .context("failed to decode instrument id")?;
352                    let resolved_precision = self.resolve_stream_price_precision(
353                        &instrument_id,
354                        fixed_instrument_id,
355                        &mut fixed_price_precision,
356                    )?;
357                    let (item1, item2) = decode_record(
358                        &record,
359                        instrument_id,
360                        resolved_precision,
361                        None,
362                        include_trades,
363                        bars_timestamp_on_close.unwrap_or(true),
364                    )?;
365                    Ok(Some((item1, item2)))
366                } else {
367                    Ok(None)
368                }
369            })();
370
371            match result {
372                Ok(Some(v)) => Some(Ok(v)),
373                Ok(None) => None,
374                Err(e) => Some(Err(e)),
375            }
376        }))
377    }
378
379    /// Loads all instrument definitions from a DBN file.
380    ///
381    /// When `skip_on_error` is true, instruments that fail to decode are logged
382    /// as warnings and skipped. When false (default), any decode error is propagated.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if loading instruments fails.
387    pub fn load_instruments(
388        &mut self,
389        filepath: &Path,
390        use_exchange_as_venue: bool,
391        skip_on_error: bool,
392        decode_config: Option<&DatabentoDecodeConfig>,
393    ) -> anyhow::Result<Vec<InstrumentAny>> {
394        let instruments = if skip_on_error {
395            let mut collected = Vec::new();
396
397            for result in
398                self.read_definition_records(filepath, use_exchange_as_venue, decode_config)?
399            {
400                match result {
401                    Ok(instrument) => collected.push(instrument),
402                    Err(e) => log::warn!("Skipping instrument: {e}"),
403                }
404            }
405            collected
406        } else {
407            self.read_definition_records(filepath, use_exchange_as_venue, decode_config)?
408                .collect::<Result<Vec<_>, _>>()?
409        };
410
411        for instrument in &instruments {
412            self.price_precisions
413                .insert(instrument.id().symbol, instrument.price_precision());
414        }
415
416        Ok(instruments)
417    }
418
419    /// Loads order book delta messages from a DBN MBO schema file.
420    ///
421    /// Cannot include trades.
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if loading order book deltas fails.
426    pub fn load_order_book_deltas(
427        &self,
428        filepath: &Path,
429        instrument_id: Option<InstrumentId>,
430        price_precision: Option<u8>,
431    ) -> anyhow::Result<Vec<OrderBookDelta>> {
432        self.read_order_book_deltas(filepath, instrument_id, price_precision)?
433            .collect()
434    }
435
436    /// Reads order book delta messages from a DBN MBO schema file without collecting them.
437    ///
438    /// Cannot include trades.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if opening or decoding order book deltas fails.
443    pub fn read_order_book_deltas(
444        &self,
445        filepath: &Path,
446        instrument_id: Option<InstrumentId>,
447        price_precision: Option<u8>,
448    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<OrderBookDelta>> + '_> {
449        let decoder = Decoder::from_zstd_file(filepath)?;
450        let mut metadata_cache = if instrument_id.is_none() {
451            Some(MetadataCache::new(decoder.metadata().clone()))
452        } else {
453            None
454        };
455        let mut dbn_stream = decoder.decode_stream::<dbn::MboMsg>();
456        let fixed_instrument_id = instrument_id.is_some();
457        let mut fixed_price_precision = price_precision;
458        let mut delta_buffer = MboDeltaBuffer::default();
459        let mut terminal_error = None;
460        let mut finished = false;
461
462        Ok(std::iter::from_fn(move || {
463            loop {
464                if let Some(delta) = delta_buffer.pop_ready() {
465                    return Some(Ok(delta));
466                }
467
468                if finished {
469                    return terminal_error.take().map(Err);
470                }
471
472                let result: anyhow::Result<bool> = (|| {
473                    dbn_stream
474                        .advance()
475                        .map_err(|e| anyhow::anyhow!("Stream advance error: {e}"))?;
476
477                    let Some(rec) = dbn_stream.get() else {
478                        return Ok(false);
479                    };
480                    let record = dbn::RecordRef::from(rec);
481                    let instrument_id = self
482                        .resolve_record_instrument_id(&record, instrument_id, &mut metadata_cache)
483                        .context("failed to decode instrument id")?;
484                    let resolved_precision = self.resolve_stream_price_precision(
485                        &instrument_id,
486                        fixed_instrument_id,
487                        &mut fixed_price_precision,
488                    )?;
489                    let msg = record
490                        .get::<dbn::MboMsg>()
491                        .ok_or_else(|| anyhow::anyhow!("Failed to decode MboMsg"))?;
492                    let (delta, _trade) =
493                        decode_mbo_msg(msg, instrument_id, resolved_precision, None, false)?;
494                    delta_buffer.push(msg, instrument_id, delta);
495                    Ok(true)
496                })();
497
498                match result {
499                    Ok(true) => {}
500                    Ok(false) => {
501                        delta_buffer.finish();
502                        finished = true;
503                    }
504                    Err(e) => {
505                        delta_buffer.finish();
506                        terminal_error = Some(e);
507                        finished = true;
508                    }
509                }
510            }
511        }))
512    }
513
514    /// Loads order book depth10 snapshots from a DBN MBP-10 schema file.
515    ///
516    /// # Errors
517    ///
518    /// Returns an error if loading order book depth10 fails.
519    pub fn load_order_book_depth10(
520        &self,
521        filepath: &Path,
522        instrument_id: Option<InstrumentId>,
523        price_precision: Option<u8>,
524    ) -> anyhow::Result<Vec<OrderBookDepth10>> {
525        self.read_records::<dbn::Mbp10Msg>(filepath, instrument_id, price_precision, false, None)?
526            .filter_map(|result| match result {
527                Ok((Some(item1), _)) => {
528                    if let Data::Depth10(depth) = item1 {
529                        Some(Ok(*depth))
530                    } else {
531                        None
532                    }
533                }
534                Ok((None, _)) => None,
535                Err(e) => Some(Err(e)),
536            })
537            .collect()
538    }
539
540    /// Loads quote tick messages from a DBN MBP-1 or TBBO schema file.
541    ///
542    /// # Errors
543    ///
544    /// Returns an error if loading quotes fails.
545    pub fn load_quotes(
546        &self,
547        filepath: &Path,
548        instrument_id: Option<InstrumentId>,
549        price_precision: Option<u8>,
550    ) -> anyhow::Result<Vec<QuoteTick>> {
551        self.read_records::<dbn::Mbp1Msg>(filepath, instrument_id, price_precision, false, None)?
552            .filter_map(|result| match result {
553                Ok((Some(item1), _)) => {
554                    if let Data::Quote(quote) = item1 {
555                        Some(Ok(quote))
556                    } else {
557                        None
558                    }
559                }
560                Ok((None, _)) => None,
561                Err(e) => Some(Err(e)),
562            })
563            .collect()
564    }
565
566    /// Loads best bid/offer quote messages from a DBN BBO schema file.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if loading BBO quotes fails.
571    pub fn load_bbo_quotes(
572        &self,
573        filepath: &Path,
574        instrument_id: Option<InstrumentId>,
575        price_precision: Option<u8>,
576    ) -> anyhow::Result<Vec<QuoteTick>> {
577        self.read_records::<dbn::BboMsg>(filepath, instrument_id, price_precision, false, None)?
578            .filter_map(|result| match result {
579                Ok((Some(item1), _)) => {
580                    if let Data::Quote(quote) = item1 {
581                        Some(Ok(quote))
582                    } else {
583                        None
584                    }
585                }
586                Ok((None, _)) => None,
587                Err(e) => Some(Err(e)),
588            })
589            .collect()
590    }
591
592    /// Loads consolidated MBP-1 quote messages from a DBN CMBP-1 schema file.
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if loading consolidated MBP-1 quotes fails.
597    pub fn load_cmbp_quotes(
598        &self,
599        filepath: &Path,
600        instrument_id: Option<InstrumentId>,
601        price_precision: Option<u8>,
602    ) -> anyhow::Result<Vec<QuoteTick>> {
603        self.read_records::<dbn::Cmbp1Msg>(filepath, instrument_id, price_precision, false, None)?
604            .filter_map(|result| match result {
605                Ok((Some(item1), _)) => {
606                    if let Data::Quote(quote) = item1 {
607                        Some(Ok(quote))
608                    } else {
609                        None
610                    }
611                }
612                Ok((None, _)) => None,
613                Err(e) => Some(Err(e)),
614            })
615            .collect()
616    }
617
618    /// Loads consolidated best bid/offer quote messages from a DBN CBBO schema file.
619    ///
620    /// # Errors
621    ///
622    /// Returns an error if loading consolidated BBO quotes fails.
623    pub fn load_cbbo_quotes(
624        &self,
625        filepath: &Path,
626        instrument_id: Option<InstrumentId>,
627        price_precision: Option<u8>,
628    ) -> anyhow::Result<Vec<QuoteTick>> {
629        self.read_records::<dbn::CbboMsg>(filepath, instrument_id, price_precision, false, None)?
630            .filter_map(|result| match result {
631                Ok((Some(item1), _)) => {
632                    if let Data::Quote(quote) = item1 {
633                        Some(Ok(quote))
634                    } else {
635                        None
636                    }
637                }
638                Ok((None, _)) => None,
639                Err(e) => Some(Err(e)),
640            })
641            .collect()
642    }
643
644    /// Loads trade messages from a DBN TBBO schema file.
645    ///
646    /// # Errors
647    ///
648    /// Returns an error if loading TBBO trades fails.
649    pub fn load_tbbo_trades(
650        &self,
651        filepath: &Path,
652        instrument_id: Option<InstrumentId>,
653        price_precision: Option<u8>,
654    ) -> anyhow::Result<Vec<TradeTick>> {
655        self.read_records::<dbn::TbboMsg>(filepath, instrument_id, price_precision, true, None)?
656            .filter_map(|result| match result {
657                Ok((_, maybe_item2)) => {
658                    if let Some(Data::Trade(trade)) = maybe_item2 {
659                        Some(Ok(trade))
660                    } else {
661                        None
662                    }
663                }
664                Err(e) => Some(Err(e)),
665            })
666            .collect()
667    }
668
669    /// Loads trade messages from a DBN TCBBO schema file.
670    ///
671    /// # Errors
672    ///
673    /// Returns an error if loading TCBBO trades fails.
674    pub fn load_tcbbo_trades(
675        &self,
676        filepath: &Path,
677        instrument_id: Option<InstrumentId>,
678        price_precision: Option<u8>,
679    ) -> anyhow::Result<Vec<TradeTick>> {
680        self.read_records::<dbn::TcbboMsg>(filepath, instrument_id, price_precision, true, None)?
681            .filter_map(|result| match result {
682                Ok((_, maybe_item2)) => {
683                    if let Some(Data::Trade(trade)) = maybe_item2 {
684                        Some(Ok(trade))
685                    } else {
686                        None
687                    }
688                }
689                Err(e) => Some(Err(e)),
690            })
691            .collect()
692    }
693
694    /// Loads trade messages from a DBN TRADES schema file.
695    ///
696    /// # Errors
697    ///
698    /// Returns an error if loading trades fails.
699    pub fn load_trades(
700        &self,
701        filepath: &Path,
702        instrument_id: Option<InstrumentId>,
703        price_precision: Option<u8>,
704    ) -> anyhow::Result<Vec<TradeTick>> {
705        self.read_records::<dbn::TradeMsg>(filepath, instrument_id, price_precision, false, None)?
706            .filter_map(|result| match result {
707                Ok((Some(item1), _)) => {
708                    if let Data::Trade(trade) = item1 {
709                        Some(Ok(trade))
710                    } else {
711                        None
712                    }
713                }
714                Ok((None, _)) => None,
715                Err(e) => Some(Err(e)),
716            })
717            .collect()
718    }
719
720    /// Loads OHLCV bar messages from a DBN OHLCV schema file.
721    ///
722    /// # Errors
723    ///
724    /// Returns an error if loading bars fails.
725    pub fn load_bars(
726        &self,
727        filepath: &Path,
728        instrument_id: Option<InstrumentId>,
729        price_precision: Option<u8>,
730        timestamp_on_close: Option<bool>,
731    ) -> anyhow::Result<Vec<Bar>> {
732        self.read_records::<dbn::OhlcvMsg>(
733            filepath,
734            instrument_id,
735            price_precision,
736            false,
737            timestamp_on_close,
738        )?
739        .filter_map(|result| match result {
740            Ok((Some(item1), _)) => {
741                if let Data::Bar(bar) = item1 {
742                    Some(Ok(bar))
743                } else {
744                    None
745                }
746            }
747            Ok((None, _)) => None,
748            Err(e) => Some(Err(e)),
749        })
750        .collect()
751    }
752
753    /// Loads instrument status messages from a DBN STATUS schema file.
754    ///
755    /// # Errors
756    ///
757    /// Returns an error if loading status records fails.
758    pub fn load_status_records<T>(
759        &self,
760        filepath: &Path,
761        instrument_id: Option<InstrumentId>,
762    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<InstrumentStatus>> + '_>
763    where
764        T: dbn::Record + dbn::HasRType + 'static,
765    {
766        let decoder = Decoder::from_zstd_file(filepath)?;
767        let mut metadata_cache = if instrument_id.is_none() {
768            Some(MetadataCache::new(decoder.metadata().clone()))
769        } else {
770            None
771        };
772        let mut dbn_stream = decoder.decode_stream::<T>();
773
774        Ok(std::iter::from_fn(move || {
775            if let Err(e) = dbn_stream.advance() {
776                return Some(Err(e.into()));
777            }
778
779            match dbn_stream.get() {
780                Some(rec) => {
781                    let record = dbn::RecordRef::from(rec);
782                    let instrument_id = match self.resolve_record_instrument_id(
783                        &record,
784                        instrument_id,
785                        &mut metadata_cache,
786                    ) {
787                        Ok(id) => id,
788                        Err(e) => return Some(Err(e)),
789                    };
790
791                    let msg = match record.get::<dbn::StatusMsg>() {
792                        Some(m) => m,
793                        None => return Some(Err(anyhow::anyhow!("Invalid `StatusMsg`"))),
794                    };
795                    let ts_init = msg.ts_recv.into();
796
797                    match decode_status_msg(msg, instrument_id, Some(ts_init)) {
798                        Ok(data) => Some(Ok(data)),
799                        Err(e) => Some(Err(e)),
800                    }
801                }
802                None => None,
803            }
804        }))
805    }
806
807    /// Reads imbalance messages from a DBN IMBALANCE schema file.
808    ///
809    /// # Errors
810    ///
811    /// Returns an error if reading imbalance records fails.
812    pub fn read_imbalance_records<T>(
813        &self,
814        filepath: &Path,
815        instrument_id: Option<InstrumentId>,
816        price_precision: Option<u8>,
817    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<DatabentoImbalance>> + '_>
818    where
819        T: dbn::Record + dbn::HasRType + 'static,
820    {
821        let decoder = Decoder::from_zstd_file(filepath)?;
822        let mut metadata_cache = if instrument_id.is_none() {
823            Some(MetadataCache::new(decoder.metadata().clone()))
824        } else {
825            None
826        };
827        let mut dbn_stream = decoder.decode_stream::<T>();
828        let fixed_instrument_id = instrument_id.is_some();
829        let mut fixed_price_precision = price_precision;
830
831        Ok(std::iter::from_fn(move || {
832            if let Err(e) = dbn_stream.advance() {
833                return Some(Err(e.into()));
834            }
835
836            match dbn_stream.get() {
837                Some(rec) => {
838                    let record = dbn::RecordRef::from(rec);
839                    let instrument_id = match self.resolve_record_instrument_id(
840                        &record,
841                        instrument_id,
842                        &mut metadata_cache,
843                    ) {
844                        Ok(id) => id,
845                        Err(e) => return Some(Err(e)),
846                    };
847                    let resolved_precision = match self.resolve_stream_price_precision(
848                        &instrument_id,
849                        fixed_instrument_id,
850                        &mut fixed_price_precision,
851                    ) {
852                        Ok(p) => p,
853                        Err(e) => return Some(Err(e)),
854                    };
855
856                    let msg = match record.get::<dbn::ImbalanceMsg>() {
857                        Some(m) => m,
858                        None => return Some(Err(anyhow::anyhow!("Invalid `ImbalanceMsg`"))),
859                    };
860                    let ts_init = msg.ts_recv.into();
861
862                    match decode_imbalance_msg(
863                        msg,
864                        instrument_id,
865                        resolved_precision,
866                        Some(ts_init),
867                    ) {
868                        Ok(data) => Some(Ok(data)),
869                        Err(e) => Some(Err(e)),
870                    }
871                }
872                None => None,
873            }
874        }))
875    }
876
877    /// Reads statistics messages from a DBN STATISTICS schema file.
878    ///
879    /// # Errors
880    ///
881    /// Returns an error if reading statistics records fails.
882    pub fn read_statistics_records<T>(
883        &self,
884        filepath: &Path,
885        instrument_id: Option<InstrumentId>,
886        price_precision: Option<u8>,
887    ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<DatabentoStatistics>> + '_>
888    where
889        T: dbn::Record + dbn::HasRType + 'static,
890    {
891        let decoder = Decoder::from_zstd_file(filepath)?;
892        let mut metadata_cache = if instrument_id.is_none() {
893            Some(MetadataCache::new(decoder.metadata().clone()))
894        } else {
895            None
896        };
897        let mut dbn_stream = decoder.decode_stream::<T>();
898        let fixed_instrument_id = instrument_id.is_some();
899        let mut fixed_price_precision = price_precision;
900
901        // Loop over skipped records so one unsupported stat_type does not terminate
902        // the stream; precheck before precision resolution.
903        Ok(std::iter::from_fn(move || {
904            loop {
905                if let Err(e) = dbn_stream.advance() {
906                    return Some(Err(e.into()));
907                }
908
909                let rec = dbn_stream.get()?;
910                let record = dbn::RecordRef::from(rec);
911                let msg = match record.get::<dbn::StatMsg>() {
912                    Some(m) => m,
913                    None => return Some(Err(anyhow::anyhow!("Invalid `StatMsg`"))),
914                };
915
916                if !is_supported_stat_type(msg.stat_type) {
917                    log::warn!("Skipping unsupported `stat_type` {}", msg.stat_type);
918                    continue;
919                }
920
921                let instrument_id = match self.resolve_record_instrument_id(
922                    &record,
923                    instrument_id,
924                    &mut metadata_cache,
925                ) {
926                    Ok(id) => id,
927                    Err(e) => return Some(Err(e)),
928                };
929                let resolved_precision = match self.resolve_stream_price_precision(
930                    &instrument_id,
931                    fixed_instrument_id,
932                    &mut fixed_price_precision,
933                ) {
934                    Ok(p) => p,
935                    Err(e) => return Some(Err(e)),
936                };
937                let ts_init = msg.ts_recv.into();
938
939                match decode_statistics_msg(msg, instrument_id, resolved_precision, Some(ts_init)) {
940                    Ok(Some(data)) => return Some(Ok(data)),
941                    Ok(None) => {}
942                    Err(e) => return Some(Err(e)),
943                }
944            }
945        }))
946    }
947
948    fn resolve_record_instrument_id(
949        &self,
950        record: &dbn::RecordRef,
951        instrument_id: Option<InstrumentId>,
952        metadata_cache: &mut Option<MetadataCache>,
953    ) -> anyhow::Result<InstrumentId> {
954        if let Some(instrument_id) = instrument_id {
955            return Ok(instrument_id);
956        }
957
958        let metadata_cache = metadata_cache
959            .as_mut()
960            .ok_or_else(|| anyhow::anyhow!("missing metadata cache for dynamic instrument id"))?;
961
962        decode_nautilus_instrument_id(
963            record,
964            metadata_cache,
965            &self.publisher_venue_map,
966            &self.symbol_venue_map,
967        )
968    }
969
970    fn resolve_stream_price_precision(
971        &self,
972        instrument_id: &InstrumentId,
973        fixed_instrument_id: bool,
974        fixed_price_precision: &mut Option<u8>,
975    ) -> anyhow::Result<u8> {
976        if let Some(precision) = *fixed_price_precision {
977            return Ok(precision);
978        }
979
980        let precision = self.resolve_price_precision(instrument_id, None)?;
981        if fixed_instrument_id {
982            *fixed_price_precision = Some(precision);
983        }
984
985        Ok(precision)
986    }
987}
988
989/// Applies default venue-to-dataset mappings for consolidated Databento feeds.
990/// GLBX.MDP3 covers CME Globex exchange MICs; OPRA.PILLAR covers OPRA option venues;
991/// EQUS.MINI is the consolidated US equities default.
992fn apply_default_venue_dataset_mappings(venue_dataset_map: &mut IndexMap<Venue, Dataset>) {
993    let glbx = Dataset::from("GLBX.MDP3");
994
995    for venue in [
996        Venue::CBCM(),
997        Venue::GLBX(),
998        Venue::NYUM(),
999        Venue::XCBT(),
1000        Venue::XCEC(),
1001        Venue::XCME(),
1002        Venue::XFXS(),
1003        Venue::XNYM(),
1004    ] {
1005        _ = venue_dataset_map.insert(venue, glbx);
1006    }
1007
1008    // publishers.json seeds the consolidated EQUS venue with the unreleased EQUS.PLUS,
1009    // so pin it to EQUS.MINI, the cheapest released US equities feed.
1010    _ = venue_dataset_map.insert(Venue::from("EQUS"), Dataset::from("EQUS.MINI"));
1011
1012    let opra = Dataset::from("OPRA.PILLAR");
1013    for venue_code in [
1014        "AMXO", "XBOX", "XCBO", "EMLD", "EDGO", "GMNI", "XISX", "MCRY", "XMIO", "ARCO", "OPRA",
1015        "MPRL", "XNDQ", "XBXO", "C2OX", "XPHL", "BATO", "MXOP", "SPHR",
1016    ] {
1017        _ = venue_dataset_map.insert(Venue::from(venue_code), opra);
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use std::{
1024        ffi::c_char,
1025        fs::{File, OpenOptions},
1026        io::Write,
1027        path::{Path, PathBuf},
1028        process,
1029        sync::atomic::{AtomicU64, Ordering},
1030    };
1031
1032    use databento::dbn::encode::EncodeRecord;
1033    use nautilus_model::{
1034        enums::BookAction,
1035        types::{Price, Quantity},
1036    };
1037    use rstest::{fixture, rstest};
1038    use ustr::Ustr;
1039
1040    use super::*;
1041
1042    fn test_data_path() -> PathBuf {
1043        Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data")
1044    }
1045
1046    fn mbo_record(action: c_char, flags: u8, sequence: u32) -> dbn::MboMsg {
1047        let ts_event = 1_609_160_400_000_000_000;
1048        dbn::MboMsg {
1049            hd: dbn::RecordHeader::new::<dbn::MboMsg>(dbn::rtype::MBO, 1, 1, ts_event),
1050            order_id: 42,
1051            price: 4_800_250_000_000,
1052            size: 2,
1053            flags: dbn::FlagSet::new(flags),
1054            channel_id: 1,
1055            action,
1056            side: 'A' as c_char,
1057            ts_recv: ts_event,
1058            ts_in_delta: 0,
1059            sequence,
1060        }
1061    }
1062
1063    fn write_mbo_records(records: &[dbn::MboMsg]) -> PathBuf {
1064        static FILE_ID: AtomicU64 = AtomicU64::new(0);
1065
1066        let id = FILE_ID.fetch_add(1, Ordering::Relaxed);
1067        let path = std::env::temp_dir().join(format!(
1068            "nautilus-databento-mbo-{}-{id}.dbn.zst",
1069            process::id(),
1070        ));
1071        let metadata = dbn::Metadata::builder()
1072            .dataset("GLBX.MDP3")
1073            .schema(Some(dbn::Schema::Mbo))
1074            .start(0)
1075            .stype_in(Some(dbn::SType::InstrumentId))
1076            .stype_out(dbn::SType::InstrumentId)
1077            .build();
1078        let file = File::create(&path).unwrap();
1079        let mut encoder = dbn::encode::dbn::Encoder::with_zstd(file, &metadata).unwrap();
1080        encoder.encode_records(records).unwrap();
1081        encoder.flush().unwrap();
1082        drop(encoder);
1083
1084        path
1085    }
1086
1087    #[fixture]
1088    fn loader() -> DatabentoDataLoader {
1089        let publishers_filepath = Path::new(env!("CARGO_MANIFEST_DIR")).join("publishers.json");
1090        let mut loader = DatabentoDataLoader::new(Some(publishers_filepath)).unwrap();
1091        // ES futures test data uses precision 2 (USD cents)
1092        loader.set_price_precision(Symbol::from("ESM4"), 2);
1093        loader
1094    }
1095
1096    #[fixture]
1097    fn loader_without_seed() -> DatabentoDataLoader {
1098        let publishers_filepath = Path::new(env!("CARGO_MANIFEST_DIR")).join("publishers.json");
1099        DatabentoDataLoader::new(Some(publishers_filepath)).unwrap()
1100    }
1101
1102    // TODO: Improve the below assertions that we've actually read the records we expected
1103
1104    #[rstest]
1105    fn test_set_dataset_venue_mapping(mut loader: DatabentoDataLoader) {
1106        let dataset = Ustr::from("EQUS.PLUS");
1107        let venue = Venue::from("XNAS");
1108        loader.set_dataset_for_venue(dataset, venue);
1109
1110        let result = loader.get_dataset_for_venue(&venue).unwrap();
1111        assert_eq!(*result, dataset);
1112    }
1113
1114    #[rstest]
1115    fn test_default_venue_dataset_mappings(loader: DatabentoDataLoader) {
1116        let xcme = Venue::XCME();
1117        let result = loader.get_dataset_for_venue(&xcme).unwrap();
1118        assert_eq!(*result, Ustr::from("GLBX.MDP3"));
1119
1120        let xcbo = Venue::from("XCBO");
1121        let result = loader.get_dataset_for_venue(&xcbo).unwrap();
1122        assert_eq!(*result, Ustr::from("OPRA.PILLAR"));
1123
1124        let equs = Venue::from("EQUS");
1125        let result = loader.get_dataset_for_venue(&equs).unwrap();
1126        assert_eq!(*result, Ustr::from("EQUS.MINI"));
1127    }
1128
1129    #[rstest]
1130    #[case(test_data_path().join("test_data.definition.equity.dbn.zst"))]
1131    fn test_load_instruments(mut loader: DatabentoDataLoader, #[case] path: PathBuf) {
1132        let instruments = loader.load_instruments(&path, false, false, None).unwrap();
1133
1134        assert_eq!(instruments.len(), 2);
1135        // Definition records auto-populate the precision cache
1136        assert_eq!(
1137            loader.get_price_precisions().get(&Symbol::from("ESM4")),
1138            Some(&2)
1139        );
1140    }
1141
1142    #[rstest]
1143    fn test_load_instruments_populates_price_precisions_cache(
1144        mut loader_without_seed: DatabentoDataLoader,
1145    ) {
1146        let path = test_data_path().join("test_data.definition.equity.dbn.zst");
1147        assert!(loader_without_seed.get_price_precisions().is_empty());
1148
1149        let instruments = loader_without_seed
1150            .load_instruments(&path, false, false, None)
1151            .unwrap();
1152
1153        assert_eq!(instruments.len(), 2);
1154        for instrument in &instruments {
1155            let symbol = instrument.id().symbol;
1156            assert_eq!(
1157                loader_without_seed.get_price_precisions().get(&symbol),
1158                Some(&instrument.price_precision()),
1159                "cache missing or mismatched entry for {symbol}",
1160            );
1161        }
1162    }
1163
1164    #[rstest]
1165    fn test_read_records_errors_when_precision_unresolvable(
1166        loader_without_seed: DatabentoDataLoader,
1167    ) {
1168        let path = test_data_path().join("test_data.mbo.dbn.zst");
1169        let instrument_id = InstrumentId::from("ESM4.GLBX");
1170
1171        let result = loader_without_seed.load_order_book_deltas(&path, Some(instrument_id), None);
1172
1173        let err = result.expect_err("expected precision-resolution error");
1174        let err_msg = format!("{err}");
1175        assert!(
1176            err_msg.contains("Could not resolve `price_precision`"),
1177            "unexpected error message: {err_msg}",
1178        );
1179        assert!(
1180            err_msg.contains("ESM4.GLBX"),
1181            "error should name the instrument: {err_msg}",
1182        );
1183    }
1184
1185    #[rstest]
1186    fn test_set_price_precision_unblocks_reads(mut loader_without_seed: DatabentoDataLoader) {
1187        let path = test_data_path().join("test_data.mbo.dbn.zst");
1188        let instrument_id = InstrumentId::from("ESM4.GLBX");
1189
1190        // Without a seeded precision the read errors
1191        assert!(
1192            loader_without_seed
1193                .load_order_book_deltas(&path, Some(instrument_id), None)
1194                .is_err()
1195        );
1196
1197        loader_without_seed.set_price_precision(Symbol::from("ESM4"), 2);
1198
1199        let deltas = loader_without_seed
1200            .load_order_book_deltas(&path, Some(instrument_id), None)
1201            .unwrap();
1202        assert_eq!(deltas.len(), 2);
1203    }
1204
1205    #[rstest]
1206    fn test_resolve_price_precision_explicit_arg_overrides_cache(
1207        mut loader_without_seed: DatabentoDataLoader,
1208    ) {
1209        let instrument_id = InstrumentId::from("ESM4.GLBX");
1210        // Seed a deliberately wrong cache value so we can detect which path is taken
1211        loader_without_seed.set_price_precision(Symbol::from("ESM4"), 9);
1212
1213        let explicit = loader_without_seed
1214            .resolve_price_precision(&instrument_id, Some(2))
1215            .unwrap();
1216        assert_eq!(explicit, 2);
1217
1218        let cached = loader_without_seed
1219            .resolve_price_precision(&instrument_id, None)
1220            .unwrap();
1221        assert_eq!(cached, 9);
1222    }
1223
1224    #[rstest]
1225    fn test_resolve_price_precision_cache_miss_errors(loader_without_seed: DatabentoDataLoader) {
1226        let instrument_id = InstrumentId::from("ESM4.GLBX");
1227
1228        let err = loader_without_seed
1229            .resolve_price_precision(&instrument_id, None)
1230            .expect_err("expected cache-miss error");
1231        assert!(format!("{err}").contains("Could not resolve `price_precision`"));
1232    }
1233
1234    #[rstest]
1235    fn test_load_order_book_deltas(loader: DatabentoDataLoader) {
1236        let path = test_data_path().join("test_data.mbo.dbn.zst");
1237        let instrument_id = InstrumentId::from("ESM4.GLBX");
1238
1239        let deltas = loader
1240            .load_order_book_deltas(&path, Some(instrument_id), None)
1241            .unwrap();
1242
1243        assert_eq!(deltas.len(), 2);
1244    }
1245
1246    #[rstest]
1247    fn test_read_order_book_deltas_streams_without_collecting(loader: DatabentoDataLoader) {
1248        let path = test_data_path().join("test_data.mbo.dbn.zst");
1249        let instrument_id = InstrumentId::from("ESM4.GLBX");
1250
1251        let count = loader
1252            .read_order_book_deltas(&path, Some(instrument_id), None)
1253            .unwrap()
1254            .map(|result| result.map(|_| 1usize))
1255            .sum::<anyhow::Result<usize>>()
1256            .unwrap();
1257
1258        assert_eq!(count, 2);
1259    }
1260
1261    #[rstest]
1262    #[case::standalone(false)]
1263    #[case::legacy_inline(true)]
1264    fn test_load_order_book_deltas_preserves_event_boundary(
1265        loader: DatabentoDataLoader,
1266        #[case] inline_last: bool,
1267    ) {
1268        let instrument_id = InstrumentId::from("ESM4.GLBX");
1269        let last = dbn::flags::LAST;
1270        let mut records = vec![mbo_record(
1271            'C' as c_char,
1272            if inline_last { last } else { 0 },
1273            1,
1274        )];
1275
1276        if !inline_last {
1277            records.push(mbo_record('N' as c_char, last, 2));
1278        }
1279        let path = write_mbo_records(&records);
1280
1281        let deltas = loader
1282            .load_order_book_deltas(&path, Some(instrument_id), Some(2))
1283            .unwrap();
1284        std::fs::remove_file(path).unwrap();
1285
1286        assert_eq!(deltas.len(), 1);
1287        assert_eq!(deltas[0].instrument_id, instrument_id);
1288        assert_eq!(deltas[0].action, BookAction::Delete);
1289        assert_eq!(deltas[0].order.order_id, 42);
1290        assert_eq!(deltas[0].flags, last);
1291        assert_eq!(deltas[0].sequence, 1);
1292    }
1293
1294    #[rstest]
1295    fn test_read_order_book_deltas_drains_before_terminal_error(loader: DatabentoDataLoader) {
1296        let instrument_id = InstrumentId::from("ESM4.GLBX");
1297        let path = write_mbo_records(&[mbo_record('C' as c_char, 0, 1)]);
1298        OpenOptions::new()
1299            .append(true)
1300            .open(&path)
1301            .unwrap()
1302            .write_all(&[0; 8])
1303            .unwrap();
1304        let mut deltas = loader
1305            .read_order_book_deltas(&path, Some(instrument_id), Some(2))
1306            .unwrap();
1307
1308        let delta = deltas.next().unwrap().unwrap();
1309        let error = deltas
1310            .next()
1311            .unwrap()
1312            .expect_err("expected trailing decode error");
1313        let end = deltas.next();
1314        std::fs::remove_file(path).unwrap();
1315
1316        assert_eq!(delta.instrument_id, instrument_id);
1317        assert_eq!(delta.order.order_id, 42);
1318        assert_eq!(delta.flags, 0);
1319        assert!(format!("{error}").contains("Stream advance error"));
1320        assert!(end.is_none());
1321    }
1322
1323    #[rstest]
1324    fn test_load_order_book_depth10(loader: DatabentoDataLoader) {
1325        let path = test_data_path().join("test_data.mbp-10.dbn.zst");
1326        let instrument_id = InstrumentId::from("ESM4.GLBX");
1327
1328        let depths = loader
1329            .load_order_book_depth10(&path, Some(instrument_id), None)
1330            .unwrap();
1331
1332        assert_eq!(depths.len(), 2);
1333    }
1334
1335    #[rstest]
1336    fn test_load_quotes(loader: DatabentoDataLoader) {
1337        let path = test_data_path().join("test_data.mbp-1.dbn.zst");
1338        let instrument_id = InstrumentId::from("ESM4.GLBX");
1339
1340        let quotes = loader
1341            .load_quotes(&path, Some(instrument_id), None)
1342            .unwrap();
1343
1344        assert_eq!(quotes.len(), 2);
1345    }
1346
1347    #[rstest]
1348    #[case(test_data_path().join("test_data.bbo-1s.dbn.zst"))]
1349    #[case(test_data_path().join("test_data.bbo-1m.dbn.zst"))]
1350    fn test_load_bbo_quotes(loader: DatabentoDataLoader, #[case] path: PathBuf) {
1351        let instrument_id = InstrumentId::from("ESM4.GLBX");
1352
1353        let quotes = loader
1354            .load_bbo_quotes(&path, Some(instrument_id), None)
1355            .unwrap();
1356
1357        assert_eq!(quotes.len(), 4);
1358    }
1359
1360    #[rstest]
1361    fn test_load_cmbp_quotes(loader: DatabentoDataLoader) {
1362        let path = test_data_path().join("test_data.cmbp-1.dbn.zst");
1363        let instrument_id = InstrumentId::from("ESM4.GLBX");
1364
1365        let quotes = loader
1366            .load_cmbp_quotes(&path, Some(instrument_id), None)
1367            .unwrap();
1368
1369        // Verify exact data count
1370        assert_eq!(quotes.len(), 2);
1371
1372        // Verify first quote fields
1373        let first_quote = &quotes[0];
1374        assert_eq!(first_quote.instrument_id, instrument_id);
1375        assert_eq!(first_quote.bid_price, Price::from("3720.25"));
1376        assert_eq!(first_quote.ask_price, Price::from("3720.50"));
1377        assert_eq!(first_quote.bid_size, Quantity::from(24));
1378        assert_eq!(first_quote.ask_size, Quantity::from(11));
1379        assert_eq!(first_quote.ts_event, 1609160400006136329);
1380        assert_eq!(first_quote.ts_init, 1609160400006136329);
1381    }
1382
1383    #[rstest]
1384    fn test_load_cbbo_quotes(loader: DatabentoDataLoader) {
1385        let path = test_data_path().join("test_data.cbbo-1s.dbn.zst");
1386        let instrument_id = InstrumentId::from("ESM4.GLBX");
1387
1388        let quotes = loader
1389            .load_cbbo_quotes(&path, Some(instrument_id), None)
1390            .unwrap();
1391
1392        // Verify exact data count
1393        assert_eq!(quotes.len(), 2);
1394
1395        // Verify first quote fields
1396        let first_quote = &quotes[0];
1397        assert_eq!(first_quote.instrument_id, instrument_id);
1398        assert_eq!(first_quote.bid_price, Price::from("3720.25"));
1399        assert_eq!(first_quote.ask_price, Price::from("3720.50"));
1400        assert_eq!(first_quote.bid_size, Quantity::from(24));
1401        assert_eq!(first_quote.ask_size, Quantity::from(11));
1402        assert_eq!(first_quote.ts_event, 1609160400006136329);
1403        assert_eq!(first_quote.ts_init, 1609160400006136329);
1404    }
1405
1406    #[rstest]
1407    fn test_load_tbbo_trades(loader: DatabentoDataLoader) {
1408        let path = test_data_path().join("test_data.tbbo.dbn.zst");
1409        let instrument_id = InstrumentId::from("ESM4.GLBX");
1410
1411        let trades = loader
1412            .load_tbbo_trades(&path, Some(instrument_id), None)
1413            .unwrap();
1414
1415        assert_eq!(trades.len(), 2);
1416        assert_eq!(trades[0].instrument_id, instrument_id);
1417        assert_eq!(trades[0].price, Price::from("3720.25"));
1418        assert_eq!(trades[0].size, Quantity::from("5"));
1419    }
1420
1421    #[rstest]
1422    fn test_load_tcbbo_trades_rejects_cbbo_fixture(loader: DatabentoDataLoader) {
1423        let path = test_data_path().join("test_data.cbbo-1s.dbn.zst");
1424        let instrument_id = InstrumentId::from("ESM4.GLBX");
1425
1426        let result = loader.load_tcbbo_trades(&path, Some(instrument_id), None);
1427
1428        assert!(result.is_err());
1429    }
1430
1431    #[rstest]
1432    fn test_load_trades(loader: DatabentoDataLoader) {
1433        let path = test_data_path().join("test_data.trades.dbn.zst");
1434        let instrument_id = InstrumentId::from("ESM4.GLBX");
1435        let trades = loader
1436            .load_trades(&path, Some(instrument_id), None)
1437            .unwrap();
1438
1439        assert_eq!(trades.len(), 2);
1440    }
1441
1442    #[rstest]
1443    // #[case(test_data_path().join("test_data.ohlcv-1d.dbn.zst"))]  // TODO: Empty file (0 records)
1444    #[case(test_data_path().join("test_data.ohlcv-1h.dbn.zst"))]
1445    #[case(test_data_path().join("test_data.ohlcv-1m.dbn.zst"))]
1446    #[case(test_data_path().join("test_data.ohlcv-1s.dbn.zst"))]
1447    fn test_load_bars(loader: DatabentoDataLoader, #[case] path: PathBuf) {
1448        let instrument_id = InstrumentId::from("ESM4.GLBX");
1449        let bars = loader
1450            .load_bars(&path, Some(instrument_id), None, None)
1451            .unwrap();
1452
1453        assert_eq!(bars.len(), 2);
1454    }
1455
1456    #[rstest]
1457    #[case(test_data_path().join("test_data.ohlcv-1s.dbn.zst"))]
1458    fn test_load_bars_timestamp_on_close_true(loader: DatabentoDataLoader, #[case] path: PathBuf) {
1459        let instrument_id = InstrumentId::from("ESM4.GLBX");
1460        let bars = loader
1461            .load_bars(&path, Some(instrument_id), None, Some(true))
1462            .unwrap();
1463
1464        assert_eq!(bars.len(), 2);
1465
1466        // When bars_timestamp_on_close is true, both ts_event and ts_init should be close time
1467        for bar in &bars {
1468            assert_eq!(
1469                bar.ts_event, bar.ts_init,
1470                "ts_event and ts_init should both be close time when bars_timestamp_on_close=true"
1471            );
1472        }
1473    }
1474
1475    #[rstest]
1476    #[case(test_data_path().join("test_data.ohlcv-1s.dbn.zst"))]
1477    fn test_load_bars_timestamp_on_close_false(loader: DatabentoDataLoader, #[case] path: PathBuf) {
1478        let instrument_id = InstrumentId::from("ESM4.GLBX");
1479        let bars = loader
1480            .load_bars(&path, Some(instrument_id), None, Some(false))
1481            .unwrap();
1482
1483        assert_eq!(bars.len(), 2);
1484
1485        // When bars_timestamp_on_close is false, ts_event is open time and ts_init is close time
1486        for bar in &bars {
1487            assert_ne!(
1488                bar.ts_event, bar.ts_init,
1489                "ts_event should be open time and ts_init should be close time when bars_timestamp_on_close=false"
1490            );
1491            // For 1-second bars, ts_init (close) should be 1 second after ts_event (open)
1492            assert_eq!(bar.ts_init.as_u64(), bar.ts_event.as_u64() + 1_000_000_000);
1493        }
1494    }
1495
1496    #[rstest]
1497    #[case(test_data_path().join("test_data.ohlcv-1s.dbn.zst"), 0)]
1498    #[case(test_data_path().join("test_data.ohlcv-1s.dbn.zst"), 1)]
1499    fn test_load_bars_timestamp_comparison(
1500        loader: DatabentoDataLoader,
1501        #[case] path: PathBuf,
1502        #[case] bar_index: usize,
1503    ) {
1504        const ONE_SECOND_NS: u64 = 1_000_000_000;
1505
1506        let instrument_id = InstrumentId::from("ESM4.GLBX");
1507
1508        let bars_close = loader
1509            .load_bars(&path, Some(instrument_id), None, Some(true))
1510            .unwrap();
1511
1512        let bars_open = loader
1513            .load_bars(&path, Some(instrument_id), None, Some(false))
1514            .unwrap();
1515
1516        assert_eq!(bars_close.len(), bars_open.len());
1517        assert_eq!(bars_close.len(), 2);
1518
1519        let bar_close = &bars_close[bar_index];
1520        let bar_open = &bars_open[bar_index];
1521
1522        // Bars should have the same OHLCV data
1523        assert_eq!(bar_close.open, bar_open.open);
1524        assert_eq!(bar_close.high, bar_open.high);
1525        assert_eq!(bar_close.low, bar_open.low);
1526        assert_eq!(bar_close.close, bar_open.close);
1527        assert_eq!(bar_close.volume, bar_open.volume);
1528
1529        // The close-timestamped bar should have later timestamp than open-timestamped bar
1530        // For 1-second bars, this should be exactly 1 second difference
1531        assert!(
1532            bar_close.ts_event > bar_open.ts_event,
1533            "Close-timestamped bar should have later timestamp than open-timestamped bar"
1534        );
1535
1536        // The difference should be exactly 1 second (1_000_000_000 nanoseconds) for 1s bars
1537        assert_eq!(
1538            bar_close.ts_event.as_u64() - bar_open.ts_event.as_u64(),
1539            ONE_SECOND_NS,
1540            "Timestamp difference should be exactly 1 second for 1s bars"
1541        );
1542    }
1543
1544    #[rstest]
1545    fn test_load_status_records(loader: DatabentoDataLoader) {
1546        let path = test_data_path().join("test_data.status.dbn.zst");
1547        let instrument_id = InstrumentId::from("ESM4.GLBX");
1548
1549        let statuses = loader
1550            .load_status_records::<dbn::StatusMsg>(&path, Some(instrument_id))
1551            .unwrap()
1552            .collect::<anyhow::Result<Vec<_>>>()
1553            .unwrap();
1554
1555        // Assert total count matches Python test expectations
1556        assert_eq!(statuses.len(), 4, "Should load exactly 4 status records");
1557
1558        // Assert first record fields match Python test expectations
1559        let first = &statuses[0];
1560        assert_eq!(first.instrument_id, instrument_id);
1561        assert_eq!(first.ts_event.as_u64(), 1609110000000000000);
1562        assert_eq!(first.ts_init.as_u64(), 1609113600000000000);
1563    }
1564
1565    #[rstest]
1566    fn test_read_imbalance_records(loader: DatabentoDataLoader) {
1567        let path = test_data_path().join("test_data.imbalance.dbn.zst");
1568        let instrument_id = InstrumentId::from("ESM4.GLBX");
1569
1570        let imbalances = loader
1571            .read_imbalance_records::<dbn::ImbalanceMsg>(&path, Some(instrument_id), None)
1572            .unwrap()
1573            .collect::<anyhow::Result<Vec<_>>>()
1574            .unwrap();
1575
1576        // Assert total count
1577        assert_eq!(
1578            imbalances.len(),
1579            2,
1580            "Should load exactly 2 imbalance records"
1581        );
1582
1583        // Assert first record has required fields
1584        let first = &imbalances[0];
1585        assert_eq!(first.instrument_id, instrument_id);
1586        assert!(
1587            first.ref_price.as_f64() > 0.0,
1588            "ref_price should be positive"
1589        );
1590        assert!(first.ts_event.as_u64() > 0, "ts_event should be set");
1591        assert!(first.ts_recv.as_u64() > 0, "ts_recv should be set");
1592        assert!(first.ts_init.as_u64() > 0, "ts_init should be set");
1593    }
1594
1595    #[rstest]
1596    fn test_read_statistics_records(loader: DatabentoDataLoader) {
1597        let path = test_data_path().join("test_data.statistics.dbn.zst");
1598        let instrument_id = InstrumentId::from("ESM4.GLBX");
1599
1600        let statistics = loader
1601            .read_statistics_records::<dbn::StatMsg>(&path, Some(instrument_id), None)
1602            .unwrap()
1603            .collect::<anyhow::Result<Vec<_>>>()
1604            .unwrap();
1605
1606        // Assert total count
1607        assert_eq!(
1608            statistics.len(),
1609            2,
1610            "Should load exactly 2 statistics records"
1611        );
1612
1613        // Assert first record has required fields
1614        let first = &statistics[0];
1615        assert_eq!(first.instrument_id, instrument_id);
1616        assert!(first.ts_event.as_u64() > 0, "ts_event should be set");
1617        assert!(first.ts_recv.as_u64() > 0, "ts_recv should be set");
1618        assert!(first.ts_init.as_u64() > 0, "ts_init should be set");
1619        assert!(first.sequence > 0, "sequence should be positive");
1620    }
1621}