1use 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#[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 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 let publishers_filepath = if let Some(p) = publishers_filepath {
111 p
112 } else {
113 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 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 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 #[must_use]
160 pub const fn get_publishers(&self) -> &IndexMap<u16, DatabentoPublisher> {
161 &self.publishers_map
162 }
163
164 pub fn set_dataset_for_venue(&mut self, dataset: Dataset, venue: Venue) {
166 _ = self.venue_dataset_map.insert(venue, dataset);
167 }
168
169 #[must_use]
171 pub fn get_dataset_for_venue(&self, venue: &Venue) -> Option<&Dataset> {
172 self.venue_dataset_map.get(venue)
173 }
174
175 #[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 pub fn set_price_precision(&mut self, symbol: Symbol, price_precision: u8) {
187 self.price_precisions.insert(symbol, price_precision);
188 }
189
190 #[must_use]
192 pub const fn get_price_precisions(&self) -> &AHashMap<Symbol, u8> {
193 &self.price_precisions
194 }
195
196 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
989fn 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 _ = 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 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 #[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 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 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 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 assert_eq!(quotes.len(), 2);
1371
1372 let first_quote = "es[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 assert_eq!(quotes.len(), 2);
1394
1395 let first_quote = "es[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-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 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 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 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 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 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 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_eq!(statuses.len(), 4, "Should load exactly 4 status records");
1557
1558 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_eq!(
1578 imbalances.len(),
1579 2,
1580 "Should load exactly 2 imbalance records"
1581 );
1582
1583 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_eq!(
1608 statistics.len(),
1609 2,
1610 "Should load exactly 2 statistics records"
1611 );
1612
1613 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}