1#![expect(
22 clippy::unused_self,
23 reason = "session registration keeps backend-specific ordering logic together"
24)]
25
26use std::{collections::HashMap, path::PathBuf, sync::Arc};
27
28use ahash::AHashMap;
29use datafusion::arrow::{
30 array::{
31 Array, ArrayRef, FixedSizeListArray, GenericListArray, LargeListArray, ListArray,
32 OffsetSizeTrait, StructArray, UInt64Array,
33 },
34 compute::{SortColumn, SortOptions, concat_batches, lexsort_to_indices, take_record_batch},
35 datatypes::{DataType, Field, Schema},
36 record_batch::RecordBatch,
37};
38use futures::StreamExt;
39use indexmap::IndexMap;
40use nautilus_common::enums::Environment;
41use nautilus_core::UnixNanos;
42#[cfg(feature = "defi")]
43use nautilus_model::data::NautilusRecordType;
44use nautilus_model::{
45 data::{
46 Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus,
47 MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
48 TradeTick, close::InstrumentClose, to_variant,
49 },
50 enums::BookAction,
51};
52use nautilus_serialization::arrow::{
53 DecodeDataFromRecordBatch, DecodeTypedFromRecordBatch, KEY_TYPE_NAME, U64ColumnRef,
54 extract_column_by_name, extract_column_string, extract_decimal_column,
55 record_batch_without_identifier_column,
56};
57use object_store::path::Path as ObjectPath;
58
59use crate::{
60 backend::parquet::{
61 catalog::ParquetDataCatalog,
62 intervals::are_intervals_disjoint,
63 paths::{catalog_filename, make_object_store_path, urisafe_instrument_id},
64 },
65 catalog::types::{
66 CatalogDataType, instrument_path_prefix, parquet_data_path_prefix, record_path_prefix,
67 },
68 common::{
69 conversion::FeatherConversionSummary,
70 custom::decode_custom_batches_to_data,
71 datafusion::{filter_record_batch_by_identifier, identifiers_from_record_batches},
72 paths::{
73 catalog_data_type_from_session_feather_path, environment_directory,
74 identifier_from_session_feather_path,
75 },
76 },
77 writer::{
78 feather::recover_partial_feather_files,
79 materializer::{
80 StreamConversionOptions, apply_stream_conversion_transform,
81 coalesce_stream_conversion_batches, read_feather_record_batches,
82 restore_staged_record_batches,
83 },
84 run::FeatherSessionSource,
85 },
86};
87
88const PROMOTION_ROW_INDEX: &str = "nautilus_promotion_row_index";
89
90impl ParquetDataCatalog {
91 pub(crate) fn promote_feather_file(
92 &self,
93 source: &FeatherSessionSource,
94 feather_path: &str,
95 batches: Vec<RecordBatch>,
96 use_ts_event_for_ts_init: bool,
97 replay_identity: &str,
98 ) -> anyhow::Result<Option<FeatherConversionSummary>> {
99 let batches = Self::restore_staged_batches(batches)?;
100 if batches.is_empty() {
101 return Ok(None);
102 }
103
104 let data_type = catalog_data_type_from_session_feather_path(
105 feather_path,
106 source.environment,
107 &source.instance_id,
108 )?;
109 Self::ensure_stream_data_type(&data_type)?;
110
111 let identifier = Self::identifier_from_batch_or_path(
112 &batches[0],
113 feather_path,
114 source.environment,
115 &source.instance_id,
116 )
117 .filter(|identifier| {
118 batches.iter().all(|batch| {
119 Self::identifier_from_batch_or_path(
120 batch,
121 feather_path,
122 source.environment,
123 &source.instance_id,
124 )
125 .as_ref()
126 == Some(identifier)
127 })
128 });
129
130 self.convert_feather_batches_to_parquet(
131 source.environment,
132 &source.instance_id,
133 &data_type,
134 feather_path,
135 &batches,
136 use_ts_event_for_ts_init,
137 Some(replay_identity),
138 )?;
139 Ok(Some(FeatherConversionSummary {
140 data_type,
141 identifier,
142 feather_path: feather_path.to_string(),
143 native_version: None,
144 unmatched_identifiers: None,
145 }))
146 }
147
148 pub fn read_live_run(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
198 self.read_run(Environment::Live, instance_id)
199 }
200
201 pub fn read_backtest(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
243 self.read_run(Environment::Backtest, instance_id)
244 }
245
246 fn read_run(&self, environment: Environment, instance_id: &str) -> anyhow::Result<Vec<Data>> {
252 self.recover_partial_run_files(environment, instance_id);
253 let feather_files = self.list_feather_files(environment, instance_id, None, None)?;
254 let mut all_data: Vec<Data> = Vec::new();
255
256 for file_path in feather_files {
257 let data_type =
258 catalog_data_type_from_session_feather_path(&file_path, environment, instance_id)?;
259
260 let batches = self.read_feather_file(&file_path)?;
262
263 if batches.is_empty() {
264 continue;
265 }
266
267 if let Some(file_data) = self.decode_run_batches(&data_type, batches)? {
268 all_data.extend(file_data);
269 }
270 }
271
272 all_data.sort_by_key(HasTsInit::ts_init);
273
274 Ok(all_data)
275 }
276
277 fn decode_run_batches(
279 &self,
280 data_type: &CatalogDataType,
281 batches: Vec<RecordBatch>,
282 ) -> anyhow::Result<Option<Vec<Data>>> {
283 let data = match data_type {
284 CatalogDataType::Data(NautilusDataType::QuoteTick) => self
285 .convert_record_batches_to_data::<QuoteTick>(batches, false)?
286 .into_iter()
287 .map(Data::from)
288 .collect(),
289 CatalogDataType::Data(NautilusDataType::TradeTick) => self
290 .convert_record_batches_to_data::<TradeTick>(batches, false)?
291 .into_iter()
292 .map(Data::from)
293 .collect(),
294 CatalogDataType::Data(NautilusDataType::OrderBookDelta) => self
295 .convert_record_batches_to_data::<OrderBookDelta>(batches, false)?
296 .into_iter()
297 .map(Data::from)
298 .collect(),
299 CatalogDataType::Data(NautilusDataType::OrderBookDepth) => self
300 .convert_record_batches_to_data::<OrderBookDepth>(batches, false)?
301 .into_iter()
302 .map(Data::from)
303 .collect(),
304 CatalogDataType::Data(NautilusDataType::Bar) => self
305 .convert_record_batches_to_data::<Bar>(batches, false)?
306 .into_iter()
307 .map(Data::from)
308 .collect(),
309 CatalogDataType::Data(NautilusDataType::IndexPriceUpdate) => self
310 .convert_record_batches_to_data::<IndexPriceUpdate>(batches, false)?
311 .into_iter()
312 .map(Data::from)
313 .collect(),
314 CatalogDataType::Data(NautilusDataType::MarkPriceUpdate) => self
315 .convert_record_batches_to_data::<MarkPriceUpdate>(batches, false)?
316 .into_iter()
317 .map(Data::from)
318 .collect(),
319 CatalogDataType::Data(NautilusDataType::OptionGreeks) => self
320 .convert_record_batches_to_data::<OptionGreeks>(batches, false)?
321 .into_iter()
322 .map(Data::from)
323 .collect(),
324 CatalogDataType::Data(NautilusDataType::FundingRateUpdate) => self
325 .convert_record_batches_to_data::<FundingRateUpdate>(batches, false)?
326 .into_iter()
327 .map(Data::from)
328 .collect(),
329 CatalogDataType::Data(NautilusDataType::InstrumentStatus) => self
330 .convert_record_batches_to_data::<InstrumentStatus>(batches, false)?
331 .into_iter()
332 .map(Data::from)
333 .collect(),
334 CatalogDataType::Data(NautilusDataType::InstrumentClose) => self
335 .convert_record_batches_to_data::<InstrumentClose>(batches, false)?
336 .into_iter()
337 .map(Data::from)
338 .collect(),
339 CatalogDataType::Data(NautilusDataType::Custom { .. }) => {
340 decode_custom_batches_to_data(batches, false)?
341 }
342 _ => return Ok(None),
343 };
344
345 Ok(Some(data))
346 }
347
348 fn recover_partial_run_files(&self, environment: Environment, instance_id: &str) {
350 if self.original_uri.starts_with("file://") {
351 let directory = PathBuf::from(self.native_base_path_string())
352 .join(environment_directory(environment))
353 .join(instance_id);
354 recover_partial_feather_files(&directory);
355 }
356 }
357
358 fn list_feather_files(
361 &self,
362 environment: Environment,
363 instance_id: &str,
364 data_type: Option<&CatalogDataType>,
365 identifiers: Option<&[String]>,
366 ) -> anyhow::Result<Vec<String>> {
367 let base_dir = make_object_store_path(
368 &self.base_path,
369 [environment_directory(environment), instance_id],
370 );
371
372 let mut files = self.execute_async(|| async {
373 let prefix = ObjectPath::from(format!("{base_dir}/"));
374 let mut stream = self.object_store.list(Some(&prefix));
375 let mut feather_files = Vec::new();
376
377 while let Some(object) = stream.next().await {
378 let object = object?;
379 let path_str = object.location.to_string();
380
381 if !path_str.ends_with(".feather") {
382 continue;
383 }
384
385 let Ok(path_data_type) = catalog_data_type_from_session_feather_path(
386 &path_str,
387 environment,
388 instance_id,
389 ) else {
390 continue;
391 };
392
393 if data_type.is_some_and(|data_type| path_data_type != *data_type) {
394 continue;
395 }
396
397 let path_identifier =
398 identifier_from_session_feather_path(&path_str, environment, instance_id);
399
400 if let (Some(identifiers), Some(path_identifier)) =
401 (identifiers, path_identifier.as_deref())
402 && !Self::stream_identifier_matches(path_identifier, identifiers)
403 {
404 continue;
405 }
406
407 feather_files.push(path_str);
408 }
409
410 Ok::<Vec<String>, anyhow::Error>(feather_files)
411 })?;
412
413 files.sort();
414 Ok(files)
415 }
416
417 fn filter_batches_by_identifiers(
420 batches: Vec<RecordBatch>,
421 file_path: &str,
422 environment: Environment,
423 instance_id: &str,
424 identifiers: Option<&[String]>,
425 ) -> anyhow::Result<Vec<RecordBatch>> {
426 let Some(identifiers) = identifiers else {
427 return Ok(batches);
428 };
429
430 let path_identifier =
431 identifier_from_session_feather_path(file_path, environment, instance_id);
432
433 batches
434 .iter()
435 .filter_map(|batch| {
436 filter_record_batch_by_identifier(batch, path_identifier.as_deref(), |identifier| {
437 identifier.is_none_or(|identifier| {
438 Self::stream_identifier_matches(identifier, identifiers)
439 })
440 })
441 .transpose()
442 })
443 .collect()
444 }
445
446 fn stream_identifier_matches(candidate: &str, identifiers: &[String]) -> bool {
447 identifiers.iter().any(|id| {
448 let safe_id = urisafe_instrument_id(id);
449 candidate.contains(id) || candidate.contains(&safe_id)
450 })
451 }
452
453 fn read_feather_file(&self, file_path: &str) -> anyhow::Result<Vec<RecordBatch>> {
455 let path = self.to_object_path_parsed(file_path)?;
456
457 let batches = self.execute_async(|| async {
458 read_feather_record_batches(self.object_store.clone(), &path).await
459 })?;
460
461 Self::restore_staged_batches(batches)
462 }
463
464 fn restore_staged_batches(batches: Vec<RecordBatch>) -> anyhow::Result<Vec<RecordBatch>> {
465 let mut restored = Vec::new();
466 for batch in batches {
467 restored.extend(restore_staged_record_batches(batch)?);
468 }
469
470 Ok(restored)
471 }
472
473 fn convert_record_batches_to_data<T>(
475 &self,
476 batches: Vec<RecordBatch>,
477 use_ts_event_for_ts_init: bool,
478 ) -> anyhow::Result<Vec<T>>
479 where
480 T: DecodeDataFromRecordBatch + TryFrom<Data>,
481 {
482 let mut all_data = Vec::new();
483
484 for batch in batches {
485 let batch = apply_stream_conversion_transform(
486 &batch,
487 StreamConversionOptions {
488 use_ts_event_for_ts_init,
489 convert_bar_type_to_external: false,
490 },
491 )?;
492
493 let metadata = batch.schema().metadata().clone();
494
495 let data_vec = T::decode_data_batch(&metadata, batch)
496 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
497
498 all_data.extend(data_vec);
499 }
500
501 Ok(to_variant::<T>(all_data))
502 }
503
504 pub(crate) fn convert_record_batches_to_typed<T>(
506 &self,
507 batches: Vec<RecordBatch>,
508 ) -> anyhow::Result<Vec<T>>
509 where
510 T: DecodeTypedFromRecordBatch,
511 {
512 let mut all_data = Vec::new();
513
514 for batch in batches {
515 let metadata = batch.schema().metadata().clone();
516 let decoded = T::decode_typed_batch(&metadata, batch)
517 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
518 all_data.extend(decoded);
519 }
520
521 Ok(all_data)
522 }
523
524 pub fn convert_stream_to_data(
589 &mut self,
590 instance_id: &str,
591 data_type: &CatalogDataType,
592 environment: Environment,
593 identifiers: Option<&[String]>,
594 use_ts_event_for_ts_init: bool,
595 ) -> anyhow::Result<()> {
596 Self::ensure_stream_data_type(data_type)?;
597 self.recover_partial_run_files(environment, instance_id);
598 let feather_files =
599 self.list_feather_files(environment, instance_id, Some(data_type), identifiers)?;
600
601 for file_path in feather_files {
604 let batches = Self::filter_batches_by_identifiers(
605 self.read_feather_file(&file_path)?,
606 &file_path,
607 environment,
608 instance_id,
609 identifiers,
610 )?;
611
612 if batches.is_empty() {
613 continue;
614 }
615
616 self.convert_feather_batches_to_parquet(
617 environment,
618 instance_id,
619 data_type,
620 &file_path,
621 &batches,
622 use_ts_event_for_ts_init,
623 None,
624 )?;
625 }
626
627 Ok(())
628 }
629
630 #[expect(
631 clippy::too_many_arguments,
632 reason = "the arguments describe one Feather source and its catalog destination"
633 )]
634 fn convert_feather_batches_to_parquet(
635 &self,
636 environment: Environment,
637 instance_id: &str,
638 data_type: &CatalogDataType,
639 feather_path: &str,
640 batches: &[RecordBatch],
641 use_ts_event_for_ts_init: bool,
642 replay_identity: Option<&str>,
643 ) -> anyhow::Result<()> {
644 let mut groups: IndexMap<(Arc<Schema>, Option<String>), Vec<RecordBatch>> = IndexMap::new();
647 let mut row_offset = 0;
648
649 let mut rows = Vec::new();
650
651 for batch in batches {
652 anyhow::ensure!(
653 batch.column_by_name(PROMOTION_ROW_INDEX).is_none(),
654 "Feather source uses reserved column {PROMOTION_ROW_INDEX}"
655 );
656 let row_end = row_offset + batch.num_rows() as u64;
657 let mut fields = batch.schema().fields().to_vec();
658 fields.push(Arc::new(Field::new(
659 PROMOTION_ROW_INDEX,
660 DataType::UInt64,
661 false,
662 )));
663 let mut columns = batch.columns().to_vec();
664 columns.push(Arc::new(UInt64Array::from_iter_values(row_offset..row_end)));
665 let batch = RecordBatch::try_new(
666 Arc::new(Schema::new_with_metadata(
667 fields,
668 batch.schema().metadata().clone(),
669 )),
670 columns,
671 )?;
672 row_offset = row_end;
673 rows.extend(split_record_batch_by_identifier(&batch)?);
674 }
675
676 if *data_type == CatalogDataType::Data(NautilusDataType::OrderBookDelta) {
677 normalize_clear_precision(&mut rows)?;
678 }
679
680 for (identifier, batch) in rows {
681 groups
682 .entry((batch.schema(), identifier))
683 .or_default()
684 .push(batch);
685 }
686
687 let mut planned = Vec::new();
688
689 let mut ordinals: AHashMap<Option<String>, usize> = AHashMap::new();
692
693 for ((_, identifier), group) in groups {
694 let ordinal = ordinals.entry(identifier.clone()).or_default();
695 let group_identity = format!(
696 "{}/{}/{ordinal}",
697 replay_identity.unwrap_or(feather_path),
698 identifier.as_deref().unwrap_or_default(),
699 );
700 *ordinal += 1;
701
702 if let Some(plan) = self.plan_catalog_write(
703 environment,
704 instance_id,
705 data_type,
706 feather_path,
707 &group,
708 use_ts_event_for_ts_init,
709 group_identity,
710 )? {
711 planned.push(plan);
712 }
713 }
714
715 let mut by_directory: IndexMap<String, Vec<PlannedCatalogWrite>> = IndexMap::new();
716
717 for plan in planned {
718 by_directory
719 .entry(plan.directory.clone())
720 .or_default()
721 .push(plan);
722 }
723
724 let mut ready = Vec::new();
725
726 for (directory, plans) in by_directory {
727 ready.extend(self.ready_directory_plans(&directory, plans)?);
728 }
729
730 for mut plan in ready {
731 let row_index = plan.batch.schema().index_of(PROMOTION_ROW_INDEX)?;
732 plan.batch.remove_column(row_index);
733 self.write_parquet_file_checked(
734 &plan.directory,
735 UnixNanos::from(plan.start_ts),
736 UnixNanos::from(plan.end_ts),
737 std::slice::from_ref(&plan.batch),
738 false,
739 "File",
740 None,
741 Some(&plan.group_identity),
742 )?;
743 }
744
745 Ok(())
746 }
747
748 #[expect(
749 clippy::too_many_arguments,
750 reason = "the arguments describe one restored schema group and its catalog destination"
751 )]
752 fn plan_catalog_write(
753 &self,
754 environment: Environment,
755 instance_id: &str,
756 data_type: &CatalogDataType,
757 feather_path: &str,
758 group: &[RecordBatch],
759 use_ts_event_for_ts_init: bool,
760 group_identity: String,
761 ) -> anyhow::Result<Option<PlannedCatalogWrite>> {
762 let Some(batch) = Self::apply_stream_conversion_transforms(group, use_ts_event_for_ts_init)
763 .map_err(|e| {
764 anyhow::anyhow!(
765 "Failed to apply stream conversion transforms for {feather_path}: {e}"
766 )
767 })?
768 else {
769 return Ok(None);
770 };
771
772 let (start_ts, end_ts) = Self::ts_init_range(&batch).map_err(|e| {
773 anyhow::anyhow!("Failed to determine ts_init range for {feather_path}: {e}")
774 })?;
775
776 let identifier =
777 Self::identifier_from_batch_or_path(&batch, feather_path, environment, instance_id);
778
779 let identifier = identifier.as_deref();
780
781 let batch = record_batch_without_identifier_column(batch)?;
784
785 let directory = match data_type {
786 CatalogDataType::Data(NautilusDataType::Instrument) => {
787 let class = batch
788 .schema()
789 .metadata()
790 .get(KEY_TYPE_NAME)
791 .ok_or_else(|| anyhow::anyhow!("Staged instrument has no type_name metadata"))?
792 .parse()?;
793 self.make_path(instrument_path_prefix(&class), identifier)?
794 }
795 CatalogDataType::Data(NautilusDataType::Custom { type_name }) => {
796 self.make_path_custom_data(type_name, identifier)?
797 }
798 CatalogDataType::Data(data_type) => {
799 self.make_path(&parquet_data_path_prefix(data_type), identifier)?
800 }
801 CatalogDataType::Record(record_type) => {
802 self.make_path(&record_path_prefix(record_type), identifier)?
803 }
804 CatalogDataType::Instrument(class) => {
805 self.make_path(instrument_path_prefix(class), identifier)?
806 }
807 };
808
809 let batch = Self::with_catalog_identifier_metadata(batch, data_type, identifier)?;
810
811 Ok(Some(PlannedCatalogWrite {
812 directory,
813 start_ts,
814 end_ts,
815 batch,
816 group_identity,
817 }))
818 }
819
820 fn ready_directory_plans(
821 &self,
822 directory: &str,
823 plans: Vec<PlannedCatalogWrite>,
824 ) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
825 let plans = coalesce_overlapping_plans(plans)?;
826 let mut remaining = Vec::new();
827
828 for plan in plans {
829 let filename = catalog_filename(
830 UnixNanos::from(plan.start_ts),
831 UnixNanos::from(plan.end_ts),
832 Some(&plan.group_identity),
833 );
834 let path = format!("{directory}/{filename}");
835 if !self.file_exists(&path)? {
836 remaining.push(plan);
837 }
838 }
839
840 if remaining.is_empty() {
841 return Ok(remaining);
842 }
843
844 let existing = self.get_directory_intervals(directory)?;
845 let mut intervals = existing.clone();
846 intervals.extend(remaining.iter().map(|plan| (plan.start_ts, plan.end_ts)));
847
848 if !are_intervals_disjoint(&intervals) {
849 anyhow::bail!(
850 "Writing promoted groups for {directory} would create non-disjoint intervals. \
851 Existing intervals: {existing:?}"
852 );
853 }
854
855 Ok(remaining)
856 }
857
858 fn with_catalog_identifier_metadata(
859 batch: RecordBatch,
860 data_type: &CatalogDataType,
861 identifier: Option<&str>,
862 ) -> anyhow::Result<RecordBatch> {
863 let Some(identifier) = identifier else {
864 return Ok(batch);
865 };
866
867 let metadata_key = if *data_type == CatalogDataType::Data(NautilusDataType::Bar) {
868 "bar_type"
869 } else {
870 "instrument_id"
871 };
872
873 if batch.schema().metadata().contains_key(metadata_key) {
874 return Ok(batch);
875 }
876
877 let mut metadata = batch.schema().metadata().clone();
878 metadata.insert(metadata_key.to_string(), identifier.to_string());
879
880 let schema = Arc::new(Schema::new_with_metadata(
881 batch.schema().fields().clone(),
882 metadata,
883 ));
884 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
885 }
886
887 fn apply_stream_conversion_transforms(
888 batches: &[RecordBatch],
889 use_ts_event_for_ts_init: bool,
890 ) -> anyhow::Result<Option<RecordBatch>> {
891 coalesce_stream_conversion_batches(
892 batches,
893 StreamConversionOptions {
894 use_ts_event_for_ts_init,
895 convert_bar_type_to_external: true,
896 },
897 )
898 }
899
900 fn ts_init_range(batch: &RecordBatch) -> anyhow::Result<(u64, u64)> {
901 let ts_init = Self::ts_init_array(batch)?;
902 if ts_init.is_empty() {
903 anyhow::bail!("Cannot convert empty stream batch to parquet");
904 }
905
906 if (0..ts_init.len()).any(|row| ts_init.is_null(row)) {
907 anyhow::bail!("ts_init column contains null values");
908 }
909
910 let start = ts_init
911 .value(0)
912 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
913 let end = ts_init
914 .value(ts_init.len() - 1)
915 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
916 Ok((start, end))
917 }
918
919 fn ts_init_array(batch: &RecordBatch) -> anyhow::Result<U64ColumnRef<'_>> {
920 let ts_init_idx = batch
921 .schema()
922 .index_of("ts_init")
923 .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
924 U64ColumnRef::try_from_array(batch.column(ts_init_idx).as_ref())
925 .ok_or_else(|| anyhow::anyhow!("ts_init column has an unsupported type"))
926 }
927
928 fn identifier_from_batch_or_path(
929 batch: &RecordBatch,
930 feather_path: &str,
931 environment: Environment,
932 instance_id: &str,
933 ) -> Option<String> {
934 let metadata = batch.schema().metadata().clone();
935 if let Some(bar_type) = metadata.get("bar_type") {
936 return Some(bar_type.clone());
937 }
938
939 if let Some(instrument_id) = metadata.get("instrument_id") {
940 return Some(instrument_id.clone());
941 }
942
943 if let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch))
944 && identifiers.len() == 1
945 {
946 return identifiers.into_iter().next();
947 }
948
949 identifier_from_session_feather_path(feather_path, environment, instance_id)
950 }
951
952 fn ensure_stream_data_type(data_type: &CatalogDataType) -> anyhow::Result<()> {
953 match data_type {
954 CatalogDataType::Instrument(class) => anyhow::bail!(
957 "Streams stage instruments under the aggregate family, not {class}; \
958 pass the Instrument data type"
959 ),
960 #[cfg(feature = "defi")]
961 CatalogDataType::Data(NautilusDataType::Defi)
962 | CatalogDataType::Record(NautilusRecordType::Defi) => {
963 anyhow::bail!("Streams do not support {data_type}")
964 }
965 _ => Ok(()),
966 }
967 }
968}
969
970struct PlannedCatalogWrite {
971 directory: String,
972 start_ts: u64,
973 end_ts: u64,
974 batch: RecordBatch,
975 group_identity: String,
976}
977
978fn split_record_batch_by_identifier(
981 batch: &RecordBatch,
982) -> anyhow::Result<Vec<(Option<String>, RecordBatch)>> {
983 let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch)) else {
984 return Ok(vec![(None, batch.clone())]);
985 };
986
987 let mut split = Vec::with_capacity(identifiers.len() + 1);
988 for identifier in identifiers.into_iter().map(Some).chain([None]) {
989 if let Some(rows) =
990 filter_record_batch_by_identifier(batch, None, |value| value == identifier.as_deref())?
991 {
992 split.push((identifier, rows));
993 }
994 }
995
996 Ok(split)
997}
998
999fn normalize_clear_precision(batches: &mut [(Option<String>, RecordBatch)]) -> anyhow::Result<()> {
1000 let clear_only = batches
1001 .iter()
1002 .map(|(_, batch)| is_canonical_clear_batch(batch))
1003 .collect::<anyhow::Result<Vec<_>>>()?;
1004
1005 if !clear_only.iter().any(|clear| *clear) {
1006 return Ok(());
1007 }
1008
1009 let mut targets = AHashMap::new();
1010
1011 for ((identifier, batch), clear) in batches.iter().zip(&clear_only) {
1013 if !clear && batch.num_rows() != 0 {
1014 targets.insert(identifier.clone(), batch.schema());
1015 }
1016 }
1017
1018 for ((identifier, batch), clear) in batches.iter_mut().zip(clear_only).rev() {
1020 if batch.num_rows() == 0 {
1021 continue;
1022 }
1023
1024 if !clear {
1025 targets.insert(identifier.clone(), batch.schema());
1026 continue;
1027 }
1028
1029 let Some(target) = targets.get(identifier) else {
1030 continue;
1031 };
1032
1033 let schema = batch.schema();
1034 if schema.fields() != target.fields()
1035 || metadata_without_precision(&schema) != metadata_without_precision(target)
1036 {
1037 continue;
1038 }
1039
1040 *batch = RecordBatch::try_new(target.clone(), batch.columns().to_vec())?;
1041 }
1042
1043 Ok(())
1044}
1045
1046fn is_canonical_clear_batch(batch: &RecordBatch) -> anyhow::Result<bool> {
1047 if precision_values(batch.schema().as_ref()) != (Some("0"), Some("0")) {
1048 return Ok(false);
1049 }
1050
1051 let action_index = batch.schema().index_of("action")?;
1052 let side_index = batch.schema().index_of("side")?;
1053 let action = extract_column_string(batch.columns(), "action", action_index)?;
1054 let order_side = extract_column_string(batch.columns(), "side", side_index)?;
1055 let price = extract_decimal_column(batch, "price")?;
1056 let size = extract_decimal_column(batch, "size")?;
1057 let order_id = extract_column_by_name::<UInt64Array>(batch, "order_id", DataType::UInt64)?;
1058
1059 Ok((0..batch.num_rows()).all(|row| {
1060 action.value_opt(row) == Some(BookAction::Clear.as_ref())
1061 && order_side.value_opt(row) == Some("NO_ORDER_SIDE")
1062 && (price.is_null(row) || price.value(row) == 0)
1063 && size.is_valid(row)
1064 && size.value(row) == 0
1065 && order_id.is_valid(row)
1066 && order_id.value(row) == 0
1067 }))
1068}
1069
1070fn coalesce_overlapping_plans(
1071 mut plans: Vec<PlannedCatalogWrite>,
1072) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
1073 loop {
1074 let mut changed = false;
1075 let mut next = Vec::new();
1076
1077 while let Some(plan) = plans.pop() {
1078 if let Some(index) = next
1079 .iter()
1080 .position(|other| plan_intervals_overlap(other, &plan))
1081 {
1082 let other = next.swap_remove(index);
1083 next.push(unify_plans(other, plan)?);
1084 changed = true;
1085 } else {
1086 next.push(plan);
1087 }
1088 }
1089
1090 plans = next;
1091
1092 if !changed {
1093 break;
1094 }
1095 }
1096
1097 Ok(plans)
1098}
1099
1100fn plan_intervals_overlap(left: &PlannedCatalogWrite, right: &PlannedCatalogWrite) -> bool {
1101 left.start_ts <= right.end_ts && right.start_ts <= left.end_ts
1102}
1103
1104fn unify_plans(
1105 left: PlannedCatalogWrite,
1106 right: PlannedCatalogWrite,
1107) -> anyhow::Result<PlannedCatalogWrite> {
1108 let batch = unify_record_batches(left.batch, right.batch)?;
1109 let (start_ts, end_ts) = ParquetDataCatalog::ts_init_range(&batch)?;
1110
1111 Ok(PlannedCatalogWrite {
1112 directory: left.directory,
1113 start_ts,
1114 end_ts,
1115 batch,
1116 group_identity: left.group_identity,
1117 })
1118}
1119
1120fn unify_record_batches(left: RecordBatch, right: RecordBatch) -> anyhow::Result<RecordBatch> {
1121 if left.schema() == right.schema() {
1122 return concat_sorted(&left, &right);
1123 }
1124
1125 anyhow::ensure!(
1126 left.schema().fields() == right.schema().fields()
1127 && metadata_without_precision(left.schema().as_ref())
1128 == metadata_without_precision(right.schema().as_ref()),
1129 "overlapping promotion groups have incompatible schemas"
1130 );
1131
1132 let target = precision_target_schema(left.schema().as_ref(), right.schema().as_ref())?;
1133 let left = relabel_precision(left, &target)?;
1134 let right = relabel_precision(right, &target)?;
1135 concat_sorted(&left, &right)
1136}
1137
1138fn precision_target_schema(left: &Schema, right: &Schema) -> anyhow::Result<Schema> {
1139 if precision_values(left) == precision_values(right) {
1140 return Ok(left.clone());
1141 }
1142
1143 if is_precision_sentinel(left) && !is_precision_sentinel(right) {
1144 return Ok(right.clone());
1145 }
1146
1147 if is_precision_sentinel(right) && !is_precision_sentinel(left) {
1148 return Ok(left.clone());
1149 }
1150
1151 anyhow::bail!("overlapping promotion groups have incompatible precision metadata")
1152}
1153
1154fn relabel_precision(batch: RecordBatch, target: &Schema) -> anyhow::Result<RecordBatch> {
1155 if batch.schema().as_ref() == target {
1156 return Ok(batch);
1157 }
1158
1159 let precision_changes = precision_values(batch.schema().as_ref()) != precision_values(target);
1160 if precision_changes && batch_has_present_decimal(&batch) {
1161 anyhow::bail!(
1162 "cannot relabel precision metadata for a promotion group that contains decimal values"
1163 );
1164 }
1165
1166 Ok(RecordBatch::try_new(
1167 Arc::new(target.clone()),
1168 batch.columns().to_vec(),
1169 )?)
1170}
1171
1172fn concat_sorted(left: &RecordBatch, right: &RecordBatch) -> anyhow::Result<RecordBatch> {
1173 let schema = left.schema();
1174 let batch = concat_batches(&schema, [left, right])
1175 .map_err(|e| anyhow::anyhow!("Failed to concatenate promotion groups: {e}"))?;
1176 sort_by_ts_init(&batch)
1177}
1178
1179fn sort_by_ts_init(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1180 let ts_init = batch
1181 .schema()
1182 .index_of("ts_init")
1183 .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
1184 let original_row_index = batch
1185 .column_by_name(PROMOTION_ROW_INDEX)
1186 .cloned()
1187 .unwrap_or_else(|| Arc::new(UInt64Array::from_iter_values(0..batch.num_rows() as u64)));
1188
1189 let options = Some(SortOptions {
1190 descending: false,
1191 nulls_first: false,
1192 });
1193
1194 let indices = lexsort_to_indices(
1195 &[
1196 SortColumn {
1197 values: batch.column(ts_init).clone(),
1198 options,
1199 },
1200 SortColumn {
1201 values: original_row_index,
1202 options,
1203 },
1204 ],
1205 None,
1206 )
1207 .map_err(|e| anyhow::anyhow!("Failed to sort promotion group: {e}"))?;
1208
1209 take_record_batch(batch, &indices)
1210 .map_err(|e| anyhow::anyhow!("Failed to reorder promotion group: {e}"))
1211}
1212
1213fn metadata_without_precision(schema: &Schema) -> HashMap<String, String> {
1214 schema
1215 .metadata()
1216 .iter()
1217 .filter(|(key, _)| *key != "price_precision" && *key != "size_precision")
1218 .map(|(key, value)| (key.clone(), value.clone()))
1219 .collect()
1220}
1221
1222fn precision_values(schema: &Schema) -> (Option<&str>, Option<&str>) {
1223 (
1224 schema.metadata().get("price_precision").map(String::as_str),
1225 schema.metadata().get("size_precision").map(String::as_str),
1226 )
1227}
1228
1229fn is_precision_sentinel(schema: &Schema) -> bool {
1230 let (price, size) = precision_values(schema);
1231 (price.is_some() || size.is_some())
1232 && price.is_none_or(|value| value == "0")
1233 && size.is_none_or(|value| value == "0")
1234}
1235
1236fn batch_has_present_decimal(batch: &RecordBatch) -> bool {
1237 batch
1238 .columns()
1239 .iter()
1240 .any(|column| array_has_present_decimal(column.as_ref()))
1241}
1242
1243fn array_has_present_decimal(array: &dyn Array) -> bool {
1244 match array.data_type() {
1245 DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => {
1246 !array.is_empty() && array.null_count() < array.len()
1247 }
1248 DataType::List(_) => array
1250 .as_any()
1251 .downcast_ref::<ListArray>()
1252 .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1253 DataType::LargeList(_) => array
1254 .as_any()
1255 .downcast_ref::<LargeListArray>()
1256 .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1257 DataType::FixedSizeList(_, _) => array
1258 .as_any()
1259 .downcast_ref::<FixedSizeListArray>()
1260 .is_none_or(|list| array_has_present_decimal(list.values().as_ref())),
1261 DataType::Struct(_) => array
1262 .as_any()
1263 .downcast_ref::<StructArray>()
1264 .is_none_or(|values| {
1265 values
1266 .columns()
1267 .iter()
1268 .any(|column| array_has_present_decimal(column.as_ref()))
1269 }),
1270 _ => false,
1271 }
1272}
1273
1274fn list_values<O: OffsetSizeTrait>(list: &GenericListArray<O>) -> ArrayRef {
1275 let offsets = list.value_offsets();
1276 let start = offsets[0].as_usize();
1277 let end = offsets[offsets.len() - 1].as_usize();
1278 list.values().slice(start, end - start)
1279}
1280
1281#[cfg(test)]
1282mod promotion_benchmarks;
1283
1284#[cfg(test)]
1285mod promotion_group_tests {
1286 use std::{collections::HashMap, sync::Arc};
1287
1288 use datafusion::arrow::{
1289 array::{Array, Decimal128Array, ListArray, StringArray, UInt64Array},
1290 buffer::OffsetBuffer,
1291 datatypes::{DataType, Field, Schema},
1292 record_batch::RecordBatch,
1293 };
1294 use nautilus_common::enums::Environment;
1295 use nautilus_model::{
1296 data::{BookOrder, NautilusDataType, OrderBookDelta},
1297 enums::{BookAction, OrderSide},
1298 identifiers::InstrumentId,
1299 types::{Price, Quantity},
1300 };
1301 use nautilus_serialization::arrow::{EncodeToRecordBatch, KEY_IDENTIFIER};
1302 use rstest::rstest;
1303 use tempfile::TempDir;
1304
1305 use super::{
1306 array_has_present_decimal, normalize_clear_precision, split_record_batch_by_identifier,
1307 };
1308 use crate::{
1309 backend::parquet::{catalog::ParquetDataCatalog, io::read_parquet_from_object_store},
1310 common::storage::create_storage_backend_from_path,
1311 writer::{
1312 feather::{NAUTILUS_ARROW_METADATA_ID_COLUMN, NAUTILUS_ARROW_METADATA_JSON_COLUMN},
1313 run::FeatherSessionSource,
1314 },
1315 };
1316
1317 fn precision_batch(precision: &str, timestamps: Vec<u64>, price: Option<i128>) -> RecordBatch {
1318 let mut metadata = HashMap::new();
1319 metadata.insert("instrument_id".to_string(), "ETH/USDT.BINANCE".to_string());
1320 metadata.insert("price_precision".to_string(), precision.to_string());
1321 metadata.insert("size_precision".to_string(), "0".to_string());
1322 let price = Decimal128Array::from(vec![price; timestamps.len()])
1323 .with_precision_and_scale(38, 16)
1324 .unwrap();
1325 RecordBatch::try_new(
1326 Arc::new(Schema::new_with_metadata(
1327 vec![
1328 Field::new("ts_init", DataType::UInt64, false),
1329 Field::new("price", price.data_type().clone(), true),
1330 ],
1331 metadata,
1332 )),
1333 vec![Arc::new(UInt64Array::from(timestamps)), Arc::new(price)],
1334 )
1335 .unwrap()
1336 }
1337
1338 #[rstest]
1339 #[case::empty_row(0, false)]
1340 #[case::populated_row(1, true)]
1341 fn sliced_list_checks_only_its_rows_for_decimals(#[case] row: usize, #[case] expected: bool) {
1342 let price = Decimal128Array::from(vec![Some(123_i128)])
1343 .with_precision_and_scale(38, 16)
1344 .unwrap();
1345 let field = Arc::new(Field::new("item", price.data_type().clone(), false));
1346
1347 let list = ListArray::new(
1348 field,
1349 OffsetBuffer::new(vec![0_i32, 0, 1].into()),
1350 Arc::new(price),
1351 None,
1352 );
1353
1354 assert_eq!(array_has_present_decimal(&list.slice(row, 1)), expected);
1355 }
1356
1357 #[rstest]
1358 #[case::one_identifier(
1359 vec![Some("A"), None],
1360 vec![(Some("A"), vec![1]), (None, vec![2])],
1361 )]
1362 #[case::two_identifiers(
1363 vec![Some("A"), None, Some("B"), None],
1364 vec![(Some("A"), vec![1]), (Some("B"), vec![3]), (None, vec![2, 4])],
1365 )]
1366 fn split_by_identifier_keeps_rows_without_identifier_as_their_own_group(
1367 #[case] identifiers: Vec<Option<&str>>,
1368 #[case] expected: Vec<(Option<&str>, Vec<u64>)>,
1369 ) {
1370 let values = (1..=identifiers.len() as u64).collect::<Vec<_>>();
1371 let batch = RecordBatch::try_new(
1372 Arc::new(Schema::new(vec![
1373 Field::new("value", DataType::UInt64, false),
1374 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
1375 ])),
1376 vec![
1377 Arc::new(UInt64Array::from(values)),
1378 Arc::new(StringArray::from(identifiers)),
1379 ],
1380 )
1381 .unwrap();
1382
1383 let split = split_record_batch_by_identifier(&batch)
1384 .unwrap()
1385 .into_iter()
1386 .map(|(identifier, rows)| {
1387 let values = rows
1388 .column(0)
1389 .as_any()
1390 .downcast_ref::<UInt64Array>()
1391 .unwrap()
1392 .values()
1393 .to_vec();
1394 (identifier, values)
1395 })
1396 .collect::<Vec<_>>();
1397
1398 assert_eq!(
1399 split,
1400 expected
1401 .into_iter()
1402 .map(|(identifier, values)| (identifier.map(String::from), values))
1403 .collect::<Vec<_>>(),
1404 );
1405 }
1406
1407 #[rstest]
1408 fn clear_precision_keeps_instruments_separate() {
1409 let id_a = InstrumentId::from("AUD/USD.SIM");
1410 let id_b = InstrumentId::from("BTC/USD.SIM");
1411
1412 let add_a = OrderBookDelta::new(
1413 id_a,
1414 BookAction::Add,
1415 BookOrder::new(
1416 OrderSide::Buy,
1417 Price::from("1.23"),
1418 Quantity::from("4.500"),
1419 71,
1420 ),
1421 128,
1422 41,
1423 99.into(),
1424 100.into(),
1425 );
1426
1427 let add_b = OrderBookDelta::new(
1428 id_b,
1429 BookAction::Add,
1430 BookOrder::new(
1431 OrderSide::Sell,
1432 Price::from("12.3456"),
1433 Quantity::from("7.89000"),
1434 72,
1435 ),
1436 128,
1437 42,
1438 199.into(),
1439 200.into(),
1440 );
1441 let deltas = [
1442 OrderBookDelta::clear(id_a, 41, 99.into(), 100.into()),
1443 OrderBookDelta::clear(id_b, 42, 199.into(), 200.into()),
1444 add_a,
1445 add_b,
1446 ];
1447
1448 let mut batches = deltas
1449 .iter()
1450 .map(|delta| {
1451 (
1452 Some(delta.instrument_id.to_string()),
1453 OrderBookDelta::encode_batch(&delta.metadata(), &[*delta]).unwrap(),
1454 )
1455 })
1456 .collect::<Vec<_>>();
1457
1458 let original = batches.clone();
1459
1460 normalize_clear_precision(&mut batches).unwrap();
1461
1462 for ((identifier, batch), (original_identifier, original_batch)) in
1463 batches.iter().zip(&original)
1464 {
1465 let expected_schema = if *identifier == Some(id_a.to_string()) {
1466 original[2].1.schema()
1467 } else {
1468 original[3].1.schema()
1469 };
1470
1471 assert_eq!(identifier, original_identifier);
1472 assert_eq!(batch.schema(), expected_schema);
1473 assert_eq!(batch.columns(), original_batch.columns());
1474 }
1475 }
1476
1477 #[rstest]
1478 #[case::clear_only(false)]
1479 #[case::empty_following_batch(true)]
1480 fn clear_precision_without_following_orders(#[case] populated: bool) {
1481 let id = InstrumentId::from("AUD/USD.SIM");
1482 let clear = OrderBookDelta::clear(id, 41, 99.into(), 100.into());
1483 let clear_batch = OrderBookDelta::encode_batch(&clear.metadata(), &[clear]).unwrap();
1484
1485 let add = OrderBookDelta::new(
1486 id,
1487 BookAction::Add,
1488 BookOrder::new(
1489 OrderSide::Buy,
1490 Price::from("1.23"),
1491 Quantity::from("4.500"),
1492 71,
1493 ),
1494 128,
1495 40,
1496 49.into(),
1497 50.into(),
1498 );
1499 let add_batch = OrderBookDelta::encode_batch(&add.metadata(), &[add]).unwrap();
1500 let mut batches = Vec::new();
1501 if populated {
1502 batches.push((Some(id.to_string()), add_batch.clone()));
1503 }
1504
1505 batches.push((Some(id.to_string()), clear_batch.clone()));
1506 let clear_index = batches.len() - 1;
1507
1508 if populated {
1509 let mut metadata = add.metadata();
1510 metadata.insert("price_precision".to_string(), "4".to_string());
1511 let empty = RecordBatch::new_empty(Arc::new(
1512 add_batch.schema().as_ref().clone().with_metadata(metadata),
1513 ));
1514 batches.push((Some(id.to_string()), empty));
1515 }
1516
1517 normalize_clear_precision(&mut batches).unwrap();
1518
1519 let expected_schema = if populated {
1520 add_batch.schema()
1521 } else {
1522 clear_batch.schema()
1523 };
1524
1525 assert_eq!(batches[clear_index].1.schema(), expected_schema);
1526 assert_eq!(batches[clear_index].1.columns(), clear_batch.columns());
1527 assert_eq!(batches.len(), if populated { 3 } else { 1 });
1528 }
1529
1530 #[rstest]
1531 fn overlapping_empty_precision_group_is_promoted() {
1532 let temp = TempDir::new().unwrap();
1533 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1534 let batches = vec![
1535 precision_batch("0", vec![1, 3], None),
1536 precision_batch("2", vec![2], Some(20_000_000_000_000_000)),
1537 ];
1538
1539 catalog
1540 .convert_feather_batches_to_parquet(
1541 Environment::Backtest,
1542 "run-1",
1543 &NautilusDataType::QuoteTick.into(),
1544 "backtest/run-1/quotes_1.feather",
1545 &batches,
1546 false,
1547 Some("replay"),
1548 )
1549 .unwrap();
1550 catalog
1551 .convert_feather_batches_to_parquet(
1552 Environment::Backtest,
1553 "run-1",
1554 &NautilusDataType::QuoteTick.into(),
1555 "backtest/run-1/quotes_1.feather",
1556 &batches,
1557 false,
1558 Some("replay"),
1559 )
1560 .unwrap();
1561
1562 assert_eq!(
1563 catalog
1564 .get_intervals(
1565 &NautilusDataType::QuoteTick.into(),
1566 Some("ETH/USDT.BINANCE")
1567 )
1568 .unwrap(),
1569 vec![(1, 3)],
1570 );
1571 }
1572
1573 #[rstest]
1574 fn promotion_rejects_reserved_row_index() {
1575 let temp = TempDir::new().unwrap();
1576 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1577 let batch = precision_batch("2", vec![1], Some(1));
1578 let mut fields = batch.schema().fields().to_vec();
1579 fields.push(Arc::new(Field::new(
1580 super::PROMOTION_ROW_INDEX,
1581 DataType::UInt64,
1582 false,
1583 )));
1584 let mut columns = batch.columns().to_vec();
1585 columns.push(Arc::new(UInt64Array::from(vec![19])));
1586 let batch = RecordBatch::try_new(
1587 Arc::new(Schema::new_with_metadata(
1588 fields,
1589 batch.schema().metadata().clone(),
1590 )),
1591 columns,
1592 )
1593 .unwrap();
1594
1595 let error = catalog
1596 .convert_feather_batches_to_parquet(
1597 Environment::Backtest,
1598 "run-1",
1599 &NautilusDataType::QuoteTick.into(),
1600 "backtest/run-1/quotes_1.feather",
1601 &[batch],
1602 false,
1603 Some("replay"),
1604 )
1605 .unwrap_err();
1606
1607 assert_eq!(
1608 error.to_string(),
1609 "Feather source uses reserved column nautilus_promotion_row_index"
1610 );
1611 assert_eq!(
1612 catalog
1613 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1614 .unwrap(),
1615 Vec::<String>::new()
1616 );
1617 }
1618
1619 #[rstest]
1620 fn overlapping_incompatible_groups_write_nothing() {
1621 let temp = TempDir::new().unwrap();
1622 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1623 let batches = vec![
1624 precision_batch("2", vec![1, 3], Some(1)),
1625 precision_batch("5", vec![2], Some(2)),
1626 ];
1627
1628 let error = catalog
1629 .convert_feather_batches_to_parquet(
1630 Environment::Backtest,
1631 "run-1",
1632 &NautilusDataType::QuoteTick.into(),
1633 "backtest/run-1/quotes_1.feather",
1634 &batches,
1635 false,
1636 Some("replay"),
1637 )
1638 .unwrap_err();
1639 assert_eq!(
1640 error.to_string(),
1641 "overlapping promotion groups have incompatible precision metadata"
1642 );
1643 assert!(
1644 catalog
1645 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1646 .unwrap()
1647 .is_empty()
1648 );
1649 }
1650
1651 #[rstest]
1652 fn overlapping_groups_keep_source_order_for_equal_ts_init(
1653 #[values(false, true)] repeated_schema: bool,
1654 ) {
1655 let temp = TempDir::new().unwrap();
1656 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1657 let mut batches = vec![
1658 precision_batch("2", vec![1, 2], Some(10)),
1659 precision_batch("0", vec![2, 3], None),
1660 ];
1661
1662 if repeated_schema {
1663 batches.push(precision_batch("2", vec![2, 4], Some(30)));
1664 }
1665
1666 catalog
1667 .convert_feather_batches_to_parquet(
1668 Environment::Backtest,
1669 "run-1",
1670 &NautilusDataType::QuoteTick.into(),
1671 "backtest/run-1/quotes_1.feather",
1672 &batches,
1673 false,
1674 Some("replay"),
1675 )
1676 .unwrap();
1677
1678 let files = catalog
1679 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1680 .unwrap();
1681
1682 let (written, _) = catalog
1683 .execute_async(|| async {
1684 read_parquet_from_object_store(
1685 catalog.object_store.clone(),
1686 &object_store::path::Path::from(files[0].as_str()),
1687 )
1688 .await
1689 })
1690 .unwrap();
1691
1692 assert!(
1693 written
1694 .iter()
1695 .all(|batch| batch.column_by_name(super::PROMOTION_ROW_INDEX).is_none())
1696 );
1697
1698 let rows = written
1699 .iter()
1700 .flat_map(|batch| {
1701 let ts_init = batch
1702 .column_by_name("ts_init")
1703 .unwrap()
1704 .as_any()
1705 .downcast_ref::<UInt64Array>()
1706 .unwrap()
1707 .values()
1708 .to_vec();
1709 let price = batch
1710 .column_by_name("price")
1711 .unwrap()
1712 .as_any()
1713 .downcast_ref::<Decimal128Array>()
1714 .unwrap()
1715 .iter()
1716 .collect::<Vec<_>>();
1717 ts_init.into_iter().zip(price)
1718 })
1719 .collect::<Vec<_>>();
1720
1721 assert_eq!(files.len(), 1);
1722
1723 let expected = if repeated_schema {
1724 vec![
1725 (1, Some(10)),
1726 (2, Some(10)),
1727 (2, None),
1728 (2, Some(30)),
1729 (3, None),
1730 (4, Some(30)),
1731 ]
1732 } else {
1733 vec![(1, Some(10)), (2, Some(10)), (2, None), (3, None)]
1734 };
1735
1736 assert_eq!(rows, expected);
1737 }
1738
1739 #[rstest]
1740 fn promote_feather_file_skips_staged_batches_without_rows() {
1741 let temp = TempDir::new().unwrap();
1742 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1743 let storage =
1744 create_storage_backend_from_path(temp.path().to_str().unwrap(), None).unwrap();
1745 let source = FeatherSessionSource::new(storage, Environment::Backtest, "run-1");
1746 let staged = RecordBatch::new_empty(Arc::new(Schema::new(vec![
1747 Field::new("ts_init", DataType::UInt64, false),
1748 Field::new(NAUTILUS_ARROW_METADATA_ID_COLUMN, DataType::Utf8, false),
1749 Field::new(NAUTILUS_ARROW_METADATA_JSON_COLUMN, DataType::Utf8, false),
1750 ])));
1751
1752 let summary = catalog
1753 .promote_feather_file(
1754 &source,
1755 "backtest/run-1/quotes_1.feather",
1756 vec![staged],
1757 false,
1758 "replay",
1759 )
1760 .unwrap();
1761
1762 assert!(summary.is_none());
1763 assert!(
1764 catalog
1765 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1766 .unwrap()
1767 .is_empty()
1768 );
1769 }
1770
1771 #[rstest]
1772 fn overlapping_groups_with_different_fields_write_nothing() {
1773 let temp = TempDir::new().unwrap();
1774 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1775 let nullable = precision_batch("2", vec![2], Some(2));
1776 let schema = nullable.schema();
1777 let non_nullable = RecordBatch::try_new(
1778 Arc::new(Schema::new_with_metadata(
1779 vec![
1780 schema.field(0).clone(),
1781 schema.field(1).clone().with_nullable(false),
1782 ],
1783 schema.metadata().clone(),
1784 )),
1785 nullable.columns().to_vec(),
1786 )
1787 .unwrap();
1788 let batches = vec![precision_batch("2", vec![1, 3], Some(1)), non_nullable];
1789
1790 let error = catalog
1791 .convert_feather_batches_to_parquet(
1792 Environment::Backtest,
1793 "run-1",
1794 &NautilusDataType::QuoteTick.into(),
1795 "backtest/run-1/quotes_1.feather",
1796 &batches,
1797 false,
1798 Some("replay"),
1799 )
1800 .unwrap_err();
1801
1802 assert_eq!(
1803 error.to_string(),
1804 "overlapping promotion groups have incompatible schemas"
1805 );
1806 assert!(
1807 catalog
1808 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1809 .unwrap()
1810 .is_empty()
1811 );
1812 }
1813}
1814
1815#[cfg(test)]
1816mod stream_folder_tests {
1817 use nautilus_common::enums::Environment;
1818 use nautilus_model::data::NautilusDataType;
1819 use rstest::rstest;
1820
1821 use crate::{
1822 catalog::types::CatalogDataType, common::paths::catalog_data_type_from_session_feather_path,
1823 };
1824
1825 #[rstest]
1826 #[case("quotes", NautilusDataType::QuoteTick)]
1827 #[case("quote_tick", NautilusDataType::QuoteTick)]
1828 #[case("trades", NautilusDataType::TradeTick)]
1829 #[case("trade_tick", NautilusDataType::TradeTick)]
1830 #[case("bars", NautilusDataType::Bar)]
1831 #[case("bar", NautilusDataType::Bar)]
1832 #[case("order_book_deltas", NautilusDataType::OrderBookDelta)]
1833 #[case("order_book_delta", NautilusDataType::OrderBookDelta)]
1834 #[case("mark_prices", NautilusDataType::MarkPriceUpdate)]
1835 #[case("mark_price_update", NautilusDataType::MarkPriceUpdate)]
1836 #[case("index_prices", NautilusDataType::IndexPriceUpdate)]
1837 #[case("index_price_update", NautilusDataType::IndexPriceUpdate)]
1838 #[case("funding_rates", NautilusDataType::FundingRateUpdate)]
1839 #[case("funding_rate_update", NautilusDataType::FundingRateUpdate)]
1840 #[case("instrument_closes", NautilusDataType::InstrumentClose)]
1841 #[case("instrument_close", NautilusDataType::InstrumentClose)]
1842 fn stream_folders_parse_to_their_data_type(
1843 #[case] folder: &str,
1844 #[case] expected: NautilusDataType,
1845 ) {
1846 let path = format!("backtest/run-1/{folder}/{folder}_0.feather");
1847
1848 assert_eq!(
1849 catalog_data_type_from_session_feather_path(&path, Environment::Backtest, "run-1")
1850 .unwrap(),
1851 CatalogDataType::from(expected),
1852 );
1853 }
1854}
1855
1856#[cfg(test)]
1857mod read_run_tests {
1858 use std::{
1859 fs::{self, OpenOptions},
1860 sync::{Arc, atomic::AtomicU64},
1861 };
1862
1863 use nautilus_common::enums::Environment;
1864 use nautilus_core::UnixNanos;
1865 use nautilus_model::data::{
1866 Data, NautilusDataType, QuoteTick, TradeTick,
1867 stubs::{quote_audusd, quote_ethusdt_binance, stub_trade_ethusdt_buy},
1868 };
1869 use rstest::rstest;
1870 use tempfile::TempDir;
1871
1872 use crate::{
1873 backend::parquet::catalog::ParquetDataCatalog,
1874 catalog::types::CatalogDataType,
1875 writer::feather::{FEATHER_PARTIAL_EXTENSION, FeatherWriter, RotationConfig, WriterClock},
1876 };
1877
1878 fn quote_btc(ts: u64) -> QuoteTick {
1879 QuoteTick {
1880 instrument_id: "BTCUSDT-PERP.BINANCE".into(),
1881 ts_event: UnixNanos::from(ts),
1882 ts_init: UnixNanos::from(ts),
1883 ..quote_ethusdt_binance()
1884 }
1885 }
1886
1887 fn quote_aud(ts: u64) -> QuoteTick {
1888 QuoteTick {
1889 ts_event: UnixNanos::from(ts),
1890 ts_init: UnixNanos::from(ts),
1891 ..quote_audusd()
1892 }
1893 }
1894
1895 fn quote_eth(ts: u64) -> QuoteTick {
1896 QuoteTick {
1897 ts_event: UnixNanos::from(ts),
1898 ts_init: UnixNanos::from(ts),
1899 ..quote_ethusdt_binance()
1900 }
1901 }
1902
1903 fn trade_eth(ts: u64) -> TradeTick {
1904 TradeTick {
1905 ts_event: UnixNanos::from(ts),
1906 ts_init: UnixNanos::from(ts),
1907 ..stub_trade_ethusdt_buy()
1908 }
1909 }
1910
1911 fn run_writer(temp_dir: &TempDir) -> FeatherWriter {
1912 FeatherWriter::new(
1913 temp_dir.path().join("backtest").join("run-001"),
1914 WriterClock::Test(Arc::new(AtomicU64::new(0))),
1915 RotationConfig::NoRotation,
1916 None,
1917 None,
1918 )
1919 }
1920
1921 fn read_backtest(temp_dir: &TempDir) -> Vec<Data> {
1922 ParquetDataCatalog::new(temp_dir.path(), None, None, None, None)
1923 .read_backtest("run-001")
1924 .unwrap()
1925 }
1926
1927 fn write_crashed_run(temp_dir: &TempDir, quotes: &[QuoteTick]) {
1930 let mut writer = run_writer(temp_dir);
1931
1932 for quote in quotes {
1933 writer.write_data(Data::Quote(*quote)).unwrap();
1934 writer.flush().unwrap();
1935 }
1936
1937 writer.close().unwrap();
1938
1939 let sealed = temp_dir
1940 .path()
1941 .join("backtest/run-001/quotes/quotes_0.feather");
1942 let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
1943 fs::rename(&sealed, &partial).unwrap();
1944
1945 let file = OpenOptions::new().write(true).open(&partial).unwrap();
1947 file.set_len(file.metadata().unwrap().len() - 9).unwrap();
1948 }
1949
1950 #[rstest]
1951 fn read_backtest_returns_every_record_sorted_by_ts_init() {
1952 let temp_dir = TempDir::new().unwrap();
1953 let mut writer = run_writer(&temp_dir);
1954
1955 for data in [
1956 Data::Quote(quote_eth(3_000)),
1957 Data::Quote(quote_aud(1_000)),
1958 Data::Trade(trade_eth(2_000)),
1959 Data::Quote(quote_aud(4_000)),
1960 Data::Quote(quote_btc(5_000)),
1961 ] {
1962 writer.write_data(data).unwrap();
1963 }
1964
1965 writer.close().unwrap();
1966
1967 assert_eq!(
1968 read_backtest(&temp_dir),
1969 vec![
1970 Data::Quote(quote_aud(1_000)),
1971 Data::Trade(trade_eth(2_000)),
1972 Data::Quote(quote_eth(3_000)),
1973 Data::Quote(quote_aud(4_000)),
1974 Data::Quote(quote_btc(5_000)),
1975 ],
1976 );
1977 }
1978
1979 #[rstest]
1980 #[case::selected_identifier_first(true)]
1981 #[case::every_identifier_first(false)]
1982 fn convert_stream_to_data_repeats_across_identifier_selections(#[case] selected_first: bool) {
1983 let temp_dir = TempDir::new().unwrap();
1984 let mut writer = run_writer(&temp_dir);
1985 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
1986 writer.write_data(Data::Quote(quote_eth(2_000))).unwrap();
1987 writer.close().unwrap();
1988
1989 let selected = [quote_eth(2_000).instrument_id.to_string()];
1991
1992 let conversions = if selected_first {
1993 [Some(&selected[..]), None]
1994 } else {
1995 [None, Some(&selected[..])]
1996 };
1997
1998 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
1999
2000 for identifiers in conversions {
2001 catalog
2002 .convert_stream_to_data(
2003 "run-001",
2004 &CatalogDataType::from(NautilusDataType::QuoteTick),
2005 Environment::Backtest,
2006 identifiers,
2007 false,
2008 )
2009 .unwrap();
2010 }
2011
2012 assert_eq!(
2013 catalog
2014 .query::<QuoteTick>(None, None, None, None, None, true)
2015 .unwrap(),
2016 vec![quote_aud(1_000), quote_eth(2_000)],
2017 );
2018 }
2019
2020 #[rstest]
2021 fn read_backtest_skips_open_partial_files() {
2022 let temp_dir = TempDir::new().unwrap();
2023 let mut writer = run_writer(&temp_dir);
2024 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2025 writer.flush().unwrap();
2026
2027 let data = read_backtest(&temp_dir);
2028 writer.close().unwrap();
2029
2030 assert_eq!(data, Vec::<Data>::new());
2031 assert_eq!(
2032 read_backtest(&temp_dir),
2033 vec![Data::Quote(quote_aud(1_000))]
2034 );
2035 }
2036
2037 #[rstest]
2038 fn read_backtest_recovers_crashed_partial_files() {
2039 let temp_dir = TempDir::new().unwrap();
2040 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2041
2042 assert_eq!(
2043 read_backtest(&temp_dir),
2044 vec![Data::Quote(quote_aud(1_000))]
2045 );
2046 }
2047
2048 #[rstest]
2049 fn convert_stream_to_data_recovers_crashed_partial_files() {
2050 let temp_dir = TempDir::new().unwrap();
2051 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2052 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2053
2054 catalog
2055 .convert_stream_to_data(
2056 "run-001",
2057 &CatalogDataType::from(NautilusDataType::QuoteTick),
2058 Environment::Backtest,
2059 None,
2060 false,
2061 )
2062 .unwrap();
2063
2064 assert_eq!(
2065 catalog
2066 .query::<QuoteTick>(None, None, None, None, None, true)
2067 .unwrap(),
2068 vec![quote_aud(1_000)],
2069 );
2070 }
2071
2072 #[rstest]
2073 #[cfg(feature = "python")]
2074 fn read_backtest_reads_custom_data() {
2075 use nautilus_model::{
2076 data::{CustomData, DataType},
2077 identifiers::InstrumentId,
2078 };
2079 use nautilus_serialization::ensure_custom_data_registered;
2080
2081 use crate::test_data::RustTestCustomData;
2082
2083 ensure_custom_data_registered::<RustTestCustomData>();
2084 let instrument_id = InstrumentId::from("RUST.TEST");
2085
2086 let custom = Data::Custom(CustomData::new(
2087 Arc::new(RustTestCustomData {
2088 instrument_id,
2089 value: 1.23,
2090 flag: true,
2091 ts_event: UnixNanos::from(5_000),
2092 ts_init: UnixNanos::from(5_000),
2093 }),
2094 DataType::new("RustTestCustomData", None, Some(instrument_id.to_string())),
2095 ));
2096
2097 let temp_dir = TempDir::new().unwrap();
2098 let mut writer = run_writer(&temp_dir);
2099 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2100 writer.write_data(custom.clone()).unwrap();
2101 writer.close().unwrap();
2102
2103 assert_eq!(
2104 read_backtest(&temp_dir),
2105 vec![Data::Quote(quote_aud(1_000)), custom]
2106 );
2107 }
2108}