1#![expect(
22 clippy::unused_self,
23 reason = "session registration keeps backend-specific ordering logic together"
24)]
25
26use std::{collections::HashMap, fs, io, 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 io::read_parquet_from_object_store,
64 paths::{
65 catalog_filename, make_object_store_path, query_intersects_filename,
66 urisafe_instrument_id,
67 },
68 },
69 catalog::types::{
70 CatalogDataType, instrument_path_prefix, parquet_data_path_prefix, record_path_prefix,
71 },
72 common::{
73 conversion::FeatherConversionSummary,
74 custom::decode_custom_batches_to_data,
75 datafusion::{filter_record_batch_by_identifier, identifiers_from_record_batches},
76 paths::{
77 catalog_data_type_from_session_feather_path, environment_directory,
78 identifier_from_session_feather_path,
79 },
80 },
81 writer::{
82 feather::recover_partial_feather_files,
83 materializer::{
84 StreamConversionOptions, apply_stream_conversion_transform,
85 coalesce_stream_conversion_batches, read_feather_record_batches,
86 restore_staged_record_batches,
87 },
88 run::FeatherSessionSource,
89 },
90};
91
92const PROMOTION_ROW_INDEX: &str = "nautilus_promotion_row_index";
93
94impl ParquetDataCatalog {
95 pub(crate) fn promote_feather_file(
96 &self,
97 source: &FeatherSessionSource,
98 feather_path: &str,
99 batches: Vec<RecordBatch>,
100 use_ts_event_for_ts_init: bool,
101 replay_identity: &str,
102 ) -> anyhow::Result<Option<FeatherConversionSummary>> {
103 let batches = Self::restore_staged_batches(batches)?;
104 if batches.is_empty() {
105 return Ok(None);
106 }
107
108 let data_type = catalog_data_type_from_session_feather_path(
109 feather_path,
110 source.environment,
111 &source.instance_id,
112 )?;
113 Self::ensure_stream_data_type(&data_type)?;
114
115 let identifier = Self::identifier_from_batch_or_path(
116 &batches[0],
117 feather_path,
118 source.environment,
119 &source.instance_id,
120 )
121 .filter(|identifier| {
122 batches.iter().all(|batch| {
123 Self::identifier_from_batch_or_path(
124 batch,
125 feather_path,
126 source.environment,
127 &source.instance_id,
128 )
129 .as_ref()
130 == Some(identifier)
131 })
132 });
133
134 self.convert_feather_batches_to_parquet(
135 source.environment,
136 &source.instance_id,
137 &data_type,
138 feather_path,
139 &batches,
140 use_ts_event_for_ts_init,
141 Some(replay_identity),
142 )?;
143 Ok(Some(FeatherConversionSummary {
144 data_type,
145 identifier,
146 feather_path: feather_path.to_string(),
147 native_version: None,
148 unmatched_identifiers: None,
149 }))
150 }
151
152 pub fn read_live_run(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
204 self.read_run(Environment::Live, instance_id)
205 }
206
207 pub fn read_backtest(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
251 self.read_run(Environment::Backtest, instance_id)
252 }
253
254 fn read_run(&self, environment: Environment, instance_id: &str) -> anyhow::Result<Vec<Data>> {
260 self.recover_partial_run_files(environment, instance_id)?;
261 let feather_files = self.list_feather_files(environment, instance_id, None, None)?;
262 let mut all_data: Vec<Data> = Vec::new();
263
264 for file_path in feather_files {
265 let data_type =
266 catalog_data_type_from_session_feather_path(&file_path, environment, instance_id)?;
267
268 let batches = self.read_feather_file(&file_path)?;
270
271 if batches.is_empty() {
272 continue;
273 }
274
275 if let Some(file_data) = self.decode_run_batches(&data_type, batches)? {
276 all_data.extend(file_data);
277 }
278 }
279
280 all_data.sort_by_key(HasTsInit::ts_init);
281
282 Ok(all_data)
283 }
284
285 fn decode_run_batches(
287 &self,
288 data_type: &CatalogDataType,
289 batches: Vec<RecordBatch>,
290 ) -> anyhow::Result<Option<Vec<Data>>> {
291 let data = match data_type {
292 CatalogDataType::Data(NautilusDataType::QuoteTick) => self
293 .convert_record_batches_to_data::<QuoteTick>(batches, false)?
294 .into_iter()
295 .map(Data::from)
296 .collect(),
297 CatalogDataType::Data(NautilusDataType::TradeTick) => self
298 .convert_record_batches_to_data::<TradeTick>(batches, false)?
299 .into_iter()
300 .map(Data::from)
301 .collect(),
302 CatalogDataType::Data(NautilusDataType::OrderBookDelta) => self
303 .convert_record_batches_to_data::<OrderBookDelta>(batches, false)?
304 .into_iter()
305 .map(Data::from)
306 .collect(),
307 CatalogDataType::Data(NautilusDataType::OrderBookDepth) => self
308 .convert_record_batches_to_data::<OrderBookDepth>(batches, false)?
309 .into_iter()
310 .map(Data::from)
311 .collect(),
312 CatalogDataType::Data(NautilusDataType::Bar) => self
313 .convert_record_batches_to_data::<Bar>(batches, false)?
314 .into_iter()
315 .map(Data::from)
316 .collect(),
317 CatalogDataType::Data(NautilusDataType::IndexPriceUpdate) => self
318 .convert_record_batches_to_data::<IndexPriceUpdate>(batches, false)?
319 .into_iter()
320 .map(Data::from)
321 .collect(),
322 CatalogDataType::Data(NautilusDataType::MarkPriceUpdate) => self
323 .convert_record_batches_to_data::<MarkPriceUpdate>(batches, false)?
324 .into_iter()
325 .map(Data::from)
326 .collect(),
327 CatalogDataType::Data(NautilusDataType::OptionGreeks) => self
328 .convert_record_batches_to_data::<OptionGreeks>(batches, false)?
329 .into_iter()
330 .map(Data::from)
331 .collect(),
332 CatalogDataType::Data(NautilusDataType::FundingRateUpdate) => self
333 .convert_record_batches_to_data::<FundingRateUpdate>(batches, false)?
334 .into_iter()
335 .map(Data::from)
336 .collect(),
337 CatalogDataType::Data(NautilusDataType::InstrumentStatus) => self
338 .convert_record_batches_to_data::<InstrumentStatus>(batches, false)?
339 .into_iter()
340 .map(Data::from)
341 .collect(),
342 CatalogDataType::Data(NautilusDataType::InstrumentClose) => self
343 .convert_record_batches_to_data::<InstrumentClose>(batches, false)?
344 .into_iter()
345 .map(Data::from)
346 .collect(),
347 CatalogDataType::Data(NautilusDataType::Custom { .. }) => {
348 decode_custom_batches_to_data(batches, false)?
349 }
350 _ => return Ok(None),
351 };
352
353 Ok(Some(data))
354 }
355
356 fn recover_partial_run_files(
357 &self,
358 environment: Environment,
359 instance_id: &str,
360 ) -> anyhow::Result<()> {
361 anyhow::ensure!(
362 !instance_id.is_empty()
363 && !matches!(instance_id, "." | "..")
364 && !instance_id.contains(['/', '\\', ':', '\0']),
365 "Invalid run instance ID {instance_id:?}: expected a single path component",
366 );
367
368 if !self.original_uri.starts_with("file://") {
370 return Ok(());
371 }
372
373 let catalog_root = match fs::canonicalize(self.native_base_path_string()) {
374 Ok(path) => path,
375 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
376 Err(e) => return Err(e.into()),
377 };
378
379 let run_root = match fs::canonicalize(catalog_root.join(environment_directory(environment)))
380 {
381 Ok(path) => path,
382 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
383 Err(e) => return Err(e.into()),
384 };
385
386 anyhow::ensure!(
387 run_root.starts_with(&catalog_root) && run_root != catalog_root,
388 "Run root resolves outside the catalog: {}",
389 run_root.display(),
390 );
391
392 let directory = match fs::canonicalize(run_root.join(instance_id)) {
393 Ok(path) => path,
394 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
395 Err(e) => return Err(e.into()),
396 };
397
398 anyhow::ensure!(
399 directory.starts_with(&run_root) && directory != run_root,
400 "Run directory resolves outside the run root: {}",
401 directory.display(),
402 );
403
404 recover_partial_feather_files(&directory);
405 Ok(())
406 }
407
408 fn list_feather_files(
411 &self,
412 environment: Environment,
413 instance_id: &str,
414 data_type: Option<&CatalogDataType>,
415 identifiers: Option<&[String]>,
416 ) -> anyhow::Result<Vec<String>> {
417 let base_dir = make_object_store_path(
418 &self.base_path,
419 [environment_directory(environment), instance_id],
420 );
421
422 let mut files = self.execute_async(|| async {
423 let prefix = ObjectPath::from(format!("{base_dir}/"));
424 let mut stream = self.object_store.list(Some(&prefix));
425 let mut feather_files = Vec::new();
426
427 while let Some(object) = stream.next().await {
428 let object = object?;
429 let path_str = object.location.to_string();
430
431 if !path_str.ends_with(".feather") {
432 continue;
433 }
434
435 let Ok(path_data_type) = catalog_data_type_from_session_feather_path(
436 &path_str,
437 environment,
438 instance_id,
439 ) else {
440 continue;
441 };
442
443 if data_type.is_some_and(|data_type| path_data_type != *data_type) {
444 continue;
445 }
446
447 let path_identifier =
448 identifier_from_session_feather_path(&path_str, environment, instance_id);
449
450 if let (Some(identifiers), Some(path_identifier)) =
451 (identifiers, path_identifier.as_deref())
452 && !Self::stream_identifier_matches(path_identifier, identifiers)
453 {
454 continue;
455 }
456
457 feather_files.push(path_str);
458 }
459
460 Ok::<Vec<String>, anyhow::Error>(feather_files)
461 })?;
462
463 files.sort();
464 Ok(files)
465 }
466
467 fn filter_batches_by_identifiers(
470 batches: Vec<RecordBatch>,
471 file_path: &str,
472 environment: Environment,
473 instance_id: &str,
474 identifiers: Option<&[String]>,
475 ) -> anyhow::Result<Vec<RecordBatch>> {
476 let Some(identifiers) = identifiers else {
477 return Ok(batches);
478 };
479
480 let path_identifier =
481 identifier_from_session_feather_path(file_path, environment, instance_id);
482
483 batches
484 .iter()
485 .filter_map(|batch| {
486 filter_record_batch_by_identifier(batch, path_identifier.as_deref(), |identifier| {
487 identifier.is_none_or(|identifier| {
488 Self::stream_identifier_matches(identifier, identifiers)
489 })
490 })
491 .transpose()
492 })
493 .collect()
494 }
495
496 fn stream_identifier_matches(candidate: &str, identifiers: &[String]) -> bool {
497 identifiers.iter().any(|id| {
498 let safe_id = urisafe_instrument_id(id);
499 candidate.contains(id) || candidate.contains(&safe_id)
500 })
501 }
502
503 fn read_feather_file(&self, file_path: &str) -> anyhow::Result<Vec<RecordBatch>> {
505 let path = self.to_object_path_parsed(file_path)?;
506
507 let batches = self.execute_async(|| async {
508 read_feather_record_batches(self.object_store.clone(), &path).await
509 })?;
510
511 Self::restore_staged_batches(batches)
512 }
513
514 fn restore_staged_batches(batches: Vec<RecordBatch>) -> anyhow::Result<Vec<RecordBatch>> {
515 let mut restored = Vec::new();
516 for batch in batches {
517 restored.extend(restore_staged_record_batches(batch)?);
518 }
519
520 Ok(restored)
521 }
522
523 fn convert_record_batches_to_data<T>(
525 &self,
526 batches: Vec<RecordBatch>,
527 use_ts_event_for_ts_init: bool,
528 ) -> anyhow::Result<Vec<T>>
529 where
530 T: DecodeDataFromRecordBatch + TryFrom<Data>,
531 {
532 let mut all_data = Vec::new();
533
534 for batch in batches {
535 let batch = apply_stream_conversion_transform(
536 &batch,
537 StreamConversionOptions {
538 use_ts_event_for_ts_init,
539 convert_bar_type_to_external: false,
540 },
541 )?;
542
543 let metadata = batch.schema().metadata().clone();
544
545 let data_vec = T::decode_data_batch(&metadata, batch)
546 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
547
548 all_data.extend(data_vec);
549 }
550
551 Ok(to_variant::<T>(all_data))
552 }
553
554 pub(crate) fn convert_record_batches_to_typed<T>(
556 &self,
557 batches: Vec<RecordBatch>,
558 ) -> anyhow::Result<Vec<T>>
559 where
560 T: DecodeTypedFromRecordBatch,
561 {
562 let mut all_data = Vec::new();
563
564 for batch in batches {
565 let metadata = batch.schema().metadata().clone();
566 let decoded = T::decode_typed_batch(&metadata, batch)
567 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
568 all_data.extend(decoded);
569 }
570
571 Ok(all_data)
572 }
573
574 pub fn convert_stream_to_data(
642 &mut self,
643 instance_id: &str,
644 data_type: &CatalogDataType,
645 environment: Environment,
646 identifiers: Option<&[String]>,
647 use_ts_event_for_ts_init: bool,
648 ) -> anyhow::Result<()> {
649 Self::ensure_stream_data_type(data_type)?;
650 self.recover_partial_run_files(environment, instance_id)?;
651 let feather_files =
652 self.list_feather_files(environment, instance_id, Some(data_type), identifiers)?;
653
654 for file_path in feather_files {
657 let batches = Self::filter_batches_by_identifiers(
658 self.read_feather_file(&file_path)?,
659 &file_path,
660 environment,
661 instance_id,
662 identifiers,
663 )?;
664
665 if batches.is_empty() {
666 continue;
667 }
668
669 self.convert_feather_batches_to_parquet(
670 environment,
671 instance_id,
672 data_type,
673 &file_path,
674 &batches,
675 use_ts_event_for_ts_init,
676 None,
677 )?;
678 }
679
680 Ok(())
681 }
682
683 #[expect(
684 clippy::too_many_arguments,
685 reason = "the arguments describe one Feather source and its catalog destination"
686 )]
687 fn convert_feather_batches_to_parquet(
688 &self,
689 environment: Environment,
690 instance_id: &str,
691 data_type: &CatalogDataType,
692 feather_path: &str,
693 batches: &[RecordBatch],
694 use_ts_event_for_ts_init: bool,
695 replay_identity: Option<&str>,
696 ) -> anyhow::Result<()> {
697 let mut groups: IndexMap<(Arc<Schema>, Option<String>), Vec<RecordBatch>> = IndexMap::new();
700 let mut row_offset = 0;
701
702 let mut rows = Vec::new();
703
704 for batch in batches {
705 anyhow::ensure!(
706 batch.column_by_name(PROMOTION_ROW_INDEX).is_none(),
707 "Feather source uses reserved column {PROMOTION_ROW_INDEX}"
708 );
709 let row_end = row_offset + batch.num_rows() as u64;
710 let mut fields = batch.schema().fields().to_vec();
711 fields.push(Arc::new(Field::new(
712 PROMOTION_ROW_INDEX,
713 DataType::UInt64,
714 false,
715 )));
716 let mut columns = batch.columns().to_vec();
717 columns.push(Arc::new(UInt64Array::from_iter_values(row_offset..row_end)));
718 let batch = RecordBatch::try_new(
719 Arc::new(Schema::new_with_metadata(
720 fields,
721 batch.schema().metadata().clone(),
722 )),
723 columns,
724 )?;
725 row_offset = row_end;
726 rows.extend(split_record_batch_by_identifier(&batch)?);
727 }
728
729 if *data_type == CatalogDataType::Data(NautilusDataType::OrderBookDelta) {
730 normalize_clear_precision(&mut rows)?;
731 }
732
733 for (identifier, batch) in rows {
734 groups
735 .entry((batch.schema(), identifier))
736 .or_default()
737 .push(batch);
738 }
739
740 let mut planned = Vec::new();
741
742 let mut ordinals: AHashMap<Option<String>, usize> = AHashMap::new();
745
746 for ((_, identifier), group) in groups {
747 let ordinal = ordinals.entry(identifier.clone()).or_default();
748 let group_identity = format!(
749 "{}/{}/{ordinal}",
750 replay_identity.unwrap_or(feather_path),
751 identifier.as_deref().unwrap_or_default(),
752 );
753 *ordinal += 1;
754
755 if let Some(plan) = self.plan_catalog_write(
756 environment,
757 instance_id,
758 data_type,
759 feather_path,
760 &group,
761 use_ts_event_for_ts_init,
762 group_identity,
763 )? {
764 planned.push(plan);
765 }
766 }
767
768 let mut by_directory: IndexMap<String, Vec<PlannedCatalogWrite>> = IndexMap::new();
769
770 for plan in planned {
771 by_directory
772 .entry(plan.directory.clone())
773 .or_default()
774 .push(plan);
775 }
776
777 let mut ready = Vec::new();
778 for (directory, plans) in by_directory {
779 ready.extend(self.ready_directory_plans(data_type, &directory, plans)?);
780 }
781
782 for mut plan in ready {
783 let row_index = plan.batch.schema().index_of(PROMOTION_ROW_INDEX)?;
784 plan.batch.remove_column(row_index);
785 self.write_parquet_file_checked(
786 &plan.directory,
787 UnixNanos::from(plan.start_ts),
788 UnixNanos::from(plan.end_ts),
789 std::slice::from_ref(&plan.batch),
790 false,
791 "File",
792 None,
793 Some(&plan.group_identity),
794 )?;
795 }
796
797 Ok(())
798 }
799
800 #[expect(
801 clippy::too_many_arguments,
802 reason = "the arguments describe one restored schema group and its catalog destination"
803 )]
804 fn plan_catalog_write(
805 &self,
806 environment: Environment,
807 instance_id: &str,
808 data_type: &CatalogDataType,
809 feather_path: &str,
810 group: &[RecordBatch],
811 use_ts_event_for_ts_init: bool,
812 group_identity: String,
813 ) -> anyhow::Result<Option<PlannedCatalogWrite>> {
814 let Some(batch) = Self::apply_stream_conversion_transforms(group, use_ts_event_for_ts_init)
815 .map_err(|e| {
816 anyhow::anyhow!(
817 "Failed to apply stream conversion transforms for {feather_path}: {e}"
818 )
819 })?
820 else {
821 return Ok(None);
822 };
823
824 let (start_ts, end_ts) = Self::ts_init_range(&batch).map_err(|e| {
825 anyhow::anyhow!("Failed to determine ts_init range for {feather_path}: {e}")
826 })?;
827
828 let identifier =
829 Self::identifier_from_batch_or_path(&batch, feather_path, environment, instance_id);
830
831 let identifier = identifier.as_deref();
832
833 let batch = record_batch_without_identifier_column(batch)?;
836
837 let directory = match data_type {
838 CatalogDataType::Data(NautilusDataType::Instrument) => {
839 let class = batch
840 .schema()
841 .metadata()
842 .get(KEY_TYPE_NAME)
843 .ok_or_else(|| anyhow::anyhow!("Staged instrument has no type_name metadata"))?
844 .parse()?;
845 self.make_path(instrument_path_prefix(&class), identifier)?
846 }
847 CatalogDataType::Data(NautilusDataType::Custom { type_name }) => {
848 self.make_path_custom_data(type_name, identifier)?
849 }
850 CatalogDataType::Data(data_type) => {
851 self.make_path(&parquet_data_path_prefix(data_type), identifier)?
852 }
853 CatalogDataType::Record(record_type) => {
854 self.make_path(&record_path_prefix(record_type), identifier)?
855 }
856 CatalogDataType::Instrument(class) => {
857 self.make_path(instrument_path_prefix(class), identifier)?
858 }
859 };
860
861 let batch = Self::with_catalog_identifier_metadata(batch, data_type, identifier)?;
862
863 Ok(Some(PlannedCatalogWrite {
864 directory,
865 start_ts,
866 end_ts,
867 batch,
868 group_identity,
869 }))
870 }
871
872 fn ready_directory_plans(
873 &self,
874 data_type: &CatalogDataType,
875 directory: &str,
876 plans: Vec<PlannedCatalogWrite>,
877 ) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
878 let plans = coalesce_overlapping_plans(plans)?;
879 let mut remaining = Vec::new();
880
881 for plan in plans {
882 let filename = catalog_filename(
883 UnixNanos::from(plan.start_ts),
884 UnixNanos::from(plan.end_ts),
885 Some(&plan.group_identity),
886 );
887 let path = format!("{directory}/{filename}");
888 if !self.file_exists(&path)? {
889 remaining.push(plan);
890 }
891 }
892
893 if remaining.is_empty() {
894 return Ok(remaining);
895 }
896
897 let existing = self.get_directory_intervals(directory)?;
898
899 if *data_type == CatalogDataType::Data(NautilusDataType::Bar) {
900 let mut filtered = Vec::new();
901
902 for plan in remaining {
903 if !existing
904 .iter()
905 .any(|&(start, end)| plan.start_ts <= end && start <= plan.end_ts)
906 {
907 filtered.push(plan);
908 } else if let Some(plan) = self.remove_replayed_bar_rows(directory, plan)? {
909 filtered.push(plan);
910 }
911 }
912
913 remaining = filtered;
914 }
915
916 let mut intervals = existing.clone();
917 intervals.extend(remaining.iter().map(|plan| (plan.start_ts, plan.end_ts)));
918
919 if !are_intervals_disjoint(&intervals) {
920 anyhow::bail!(
921 "Writing promoted groups for {directory} would create non-disjoint intervals. \
922 Existing intervals: {existing:?}"
923 );
924 }
925
926 Ok(remaining)
927 }
928
929 fn remove_replayed_bar_rows(
930 &self,
931 directory: &str,
932 mut plan: PlannedCatalogWrite,
933 ) -> anyhow::Result<Option<PlannedCatalogWrite>> {
934 let row_index = plan.batch.schema().index_of(PROMOTION_ROW_INDEX)?;
935 let columns = (0..plan.batch.num_columns())
936 .filter(|&index| index != row_index)
937 .collect::<Vec<_>>();
938 let incoming = plan.batch.project(&columns)?;
939 let mut persisted = Vec::new();
940 let mut rows_by_timestamp: AHashMap<u64, Vec<(usize, usize)>> = AHashMap::new();
941
942 for file in self.list_parquet_files(directory)? {
943 if !query_intersects_filename(&file, Some(plan.start_ts), Some(plan.end_ts)) {
944 continue;
945 }
946
947 let path = self.to_object_path_parsed(&file)?;
948 let (batches, schema) = self.execute_async(|| {
949 read_parquet_from_object_store(self.object_store.clone(), &path)
950 })?;
951
952 for batch in batches {
953 let batch = RecordBatch::try_new(schema.clone(), batch.columns().to_vec())?;
954 let timestamps = Self::ts_init_array(&batch)?;
955 for row in 0..batch.num_rows() {
956 let timestamp = timestamps
957 .value(row)
958 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
959 rows_by_timestamp
960 .entry(timestamp)
961 .or_default()
962 .push((persisted.len(), row));
963 }
964
965 persisted.push(batch);
966 }
967 }
968
969 let timestamps = Self::ts_init_array(&incoming)?;
970 let mut keep = Vec::new();
971
972 for row in 0..incoming.num_rows() {
973 let timestamp = timestamps
974 .value(row)
975 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
976
977 let replayed = rows_by_timestamp.get(×tamp).is_some_and(|matches| {
978 let incoming_row = incoming.slice(row, 1);
979 matches
980 .iter()
981 .all(|&(batch, index)| persisted[batch].slice(index, 1) == incoming_row)
982 });
983
984 if !replayed {
985 keep.push(row as u64);
986 }
987 }
988
989 if keep.is_empty() {
990 return Ok(None);
991 }
992
993 plan.batch = take_record_batch(&plan.batch, &UInt64Array::from(keep))?;
994 (plan.start_ts, plan.end_ts) = Self::ts_init_range(&plan.batch)?;
995 Ok(Some(plan))
996 }
997
998 fn with_catalog_identifier_metadata(
999 batch: RecordBatch,
1000 data_type: &CatalogDataType,
1001 identifier: Option<&str>,
1002 ) -> anyhow::Result<RecordBatch> {
1003 let Some(identifier) = identifier else {
1004 return Ok(batch);
1005 };
1006
1007 let metadata_key = if *data_type == CatalogDataType::Data(NautilusDataType::Bar) {
1008 "bar_type"
1009 } else {
1010 "instrument_id"
1011 };
1012
1013 if batch.schema().metadata().contains_key(metadata_key) {
1014 return Ok(batch);
1015 }
1016
1017 let mut metadata = batch.schema().metadata().clone();
1018 metadata.insert(metadata_key.to_string(), identifier.to_string());
1019
1020 let schema = Arc::new(Schema::new_with_metadata(
1021 batch.schema().fields().clone(),
1022 metadata,
1023 ));
1024 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
1025 }
1026
1027 fn apply_stream_conversion_transforms(
1028 batches: &[RecordBatch],
1029 use_ts_event_for_ts_init: bool,
1030 ) -> anyhow::Result<Option<RecordBatch>> {
1031 coalesce_stream_conversion_batches(
1032 batches,
1033 StreamConversionOptions {
1034 use_ts_event_for_ts_init,
1035 convert_bar_type_to_external: true,
1036 },
1037 )
1038 }
1039
1040 fn ts_init_range(batch: &RecordBatch) -> anyhow::Result<(u64, u64)> {
1041 let ts_init = Self::ts_init_array(batch)?;
1042 if ts_init.is_empty() {
1043 anyhow::bail!("Cannot convert empty stream batch to parquet");
1044 }
1045
1046 if (0..ts_init.len()).any(|row| ts_init.is_null(row)) {
1047 anyhow::bail!("ts_init column contains null values");
1048 }
1049
1050 let start = ts_init
1051 .value(0)
1052 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
1053 let end = ts_init
1054 .value(ts_init.len() - 1)
1055 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
1056 Ok((start, end))
1057 }
1058
1059 fn ts_init_array(batch: &RecordBatch) -> anyhow::Result<U64ColumnRef<'_>> {
1060 let ts_init_idx = batch
1061 .schema()
1062 .index_of("ts_init")
1063 .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
1064 U64ColumnRef::try_from_array(batch.column(ts_init_idx).as_ref())
1065 .ok_or_else(|| anyhow::anyhow!("ts_init column has an unsupported type"))
1066 }
1067
1068 fn identifier_from_batch_or_path(
1069 batch: &RecordBatch,
1070 feather_path: &str,
1071 environment: Environment,
1072 instance_id: &str,
1073 ) -> Option<String> {
1074 let metadata = batch.schema().metadata().clone();
1075 if let Some(bar_type) = metadata.get("bar_type") {
1076 return Some(bar_type.clone());
1077 }
1078
1079 if let Some(instrument_id) = metadata.get("instrument_id") {
1080 return Some(instrument_id.clone());
1081 }
1082
1083 if let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch))
1084 && identifiers.len() == 1
1085 {
1086 return identifiers.into_iter().next();
1087 }
1088
1089 identifier_from_session_feather_path(feather_path, environment, instance_id)
1090 }
1091
1092 fn ensure_stream_data_type(data_type: &CatalogDataType) -> anyhow::Result<()> {
1093 match data_type {
1094 CatalogDataType::Instrument(class) => anyhow::bail!(
1097 "Streams stage instruments under the aggregate family, not {class}; \
1098 pass the Instrument data type"
1099 ),
1100 #[cfg(feature = "defi")]
1101 CatalogDataType::Data(NautilusDataType::Defi)
1102 | CatalogDataType::Record(NautilusRecordType::Defi) => {
1103 anyhow::bail!("Streams do not support {data_type}")
1104 }
1105 _ => Ok(()),
1106 }
1107 }
1108}
1109
1110struct PlannedCatalogWrite {
1111 directory: String,
1112 start_ts: u64,
1113 end_ts: u64,
1114 batch: RecordBatch,
1115 group_identity: String,
1116}
1117
1118fn split_record_batch_by_identifier(
1121 batch: &RecordBatch,
1122) -> anyhow::Result<Vec<(Option<String>, RecordBatch)>> {
1123 let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch)) else {
1124 return Ok(vec![(None, batch.clone())]);
1125 };
1126
1127 let mut split = Vec::with_capacity(identifiers.len() + 1);
1128 for identifier in identifiers.into_iter().map(Some).chain([None]) {
1129 if let Some(rows) =
1130 filter_record_batch_by_identifier(batch, None, |value| value == identifier.as_deref())?
1131 {
1132 split.push((identifier, rows));
1133 }
1134 }
1135
1136 Ok(split)
1137}
1138
1139fn normalize_clear_precision(batches: &mut [(Option<String>, RecordBatch)]) -> anyhow::Result<()> {
1140 let clear_only = batches
1141 .iter()
1142 .map(|(_, batch)| is_canonical_clear_batch(batch))
1143 .collect::<anyhow::Result<Vec<_>>>()?;
1144
1145 if !clear_only.iter().any(|clear| *clear) {
1146 return Ok(());
1147 }
1148
1149 let mut targets = AHashMap::new();
1150
1151 for ((identifier, batch), clear) in batches.iter().zip(&clear_only) {
1153 if !clear && batch.num_rows() != 0 {
1154 targets.insert(identifier.clone(), batch.schema());
1155 }
1156 }
1157
1158 for ((identifier, batch), clear) in batches.iter_mut().zip(clear_only).rev() {
1160 if batch.num_rows() == 0 {
1161 continue;
1162 }
1163
1164 if !clear {
1165 targets.insert(identifier.clone(), batch.schema());
1166 continue;
1167 }
1168
1169 let Some(target) = targets.get(identifier) else {
1170 continue;
1171 };
1172
1173 let schema = batch.schema();
1174 if schema.fields() != target.fields()
1175 || metadata_without_precision(&schema) != metadata_without_precision(target)
1176 {
1177 continue;
1178 }
1179
1180 *batch = RecordBatch::try_new(target.clone(), batch.columns().to_vec())?;
1181 }
1182
1183 Ok(())
1184}
1185
1186fn is_canonical_clear_batch(batch: &RecordBatch) -> anyhow::Result<bool> {
1187 if precision_values(batch.schema().as_ref()) != (Some("0"), Some("0")) {
1188 return Ok(false);
1189 }
1190
1191 let action_index = batch.schema().index_of("action")?;
1192 let side_index = batch.schema().index_of("side")?;
1193 let action = extract_column_string(batch.columns(), "action", action_index)?;
1194 let order_side = extract_column_string(batch.columns(), "side", side_index)?;
1195 let price = extract_decimal_column(batch, "price")?;
1196 let size = extract_decimal_column(batch, "size")?;
1197 let order_id = extract_column_by_name::<UInt64Array>(batch, "order_id", DataType::UInt64)?;
1198
1199 Ok((0..batch.num_rows()).all(|row| {
1200 action.value_opt(row) == Some(BookAction::Clear.as_ref())
1201 && order_side.value_opt(row) == Some("NO_ORDER_SIDE")
1202 && (price.is_null(row) || price.value(row) == 0)
1203 && size.is_valid(row)
1204 && size.value(row) == 0
1205 && order_id.is_valid(row)
1206 && order_id.value(row) == 0
1207 }))
1208}
1209
1210fn coalesce_overlapping_plans(
1211 mut plans: Vec<PlannedCatalogWrite>,
1212) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
1213 loop {
1214 let mut changed = false;
1215 let mut next = Vec::new();
1216
1217 while let Some(plan) = plans.pop() {
1218 if let Some(index) = next
1219 .iter()
1220 .position(|other| plan_intervals_overlap(other, &plan))
1221 {
1222 let other = next.swap_remove(index);
1223 next.push(unify_plans(other, plan)?);
1224 changed = true;
1225 } else {
1226 next.push(plan);
1227 }
1228 }
1229
1230 plans = next;
1231
1232 if !changed {
1233 break;
1234 }
1235 }
1236
1237 Ok(plans)
1238}
1239
1240fn plan_intervals_overlap(left: &PlannedCatalogWrite, right: &PlannedCatalogWrite) -> bool {
1241 left.start_ts <= right.end_ts && right.start_ts <= left.end_ts
1242}
1243
1244fn unify_plans(
1245 left: PlannedCatalogWrite,
1246 right: PlannedCatalogWrite,
1247) -> anyhow::Result<PlannedCatalogWrite> {
1248 let batch = unify_record_batches(left.batch, right.batch)?;
1249 let (start_ts, end_ts) = ParquetDataCatalog::ts_init_range(&batch)?;
1250
1251 Ok(PlannedCatalogWrite {
1252 directory: left.directory,
1253 start_ts,
1254 end_ts,
1255 batch,
1256 group_identity: left.group_identity,
1257 })
1258}
1259
1260fn unify_record_batches(left: RecordBatch, right: RecordBatch) -> anyhow::Result<RecordBatch> {
1261 if left.schema() == right.schema() {
1262 return concat_sorted(&left, &right);
1263 }
1264
1265 anyhow::ensure!(
1266 left.schema().fields() == right.schema().fields()
1267 && metadata_without_precision(left.schema().as_ref())
1268 == metadata_without_precision(right.schema().as_ref()),
1269 "overlapping promotion groups have incompatible schemas"
1270 );
1271
1272 let target = precision_target_schema(left.schema().as_ref(), right.schema().as_ref())?;
1273 let left = relabel_precision(left, &target)?;
1274 let right = relabel_precision(right, &target)?;
1275 concat_sorted(&left, &right)
1276}
1277
1278fn precision_target_schema(left: &Schema, right: &Schema) -> anyhow::Result<Schema> {
1279 if precision_values(left) == precision_values(right) {
1280 return Ok(left.clone());
1281 }
1282
1283 if is_precision_sentinel(left) && !is_precision_sentinel(right) {
1284 return Ok(right.clone());
1285 }
1286
1287 if is_precision_sentinel(right) && !is_precision_sentinel(left) {
1288 return Ok(left.clone());
1289 }
1290
1291 anyhow::bail!("overlapping promotion groups have incompatible precision metadata")
1292}
1293
1294fn relabel_precision(batch: RecordBatch, target: &Schema) -> anyhow::Result<RecordBatch> {
1295 if batch.schema().as_ref() == target {
1296 return Ok(batch);
1297 }
1298
1299 let precision_changes = precision_values(batch.schema().as_ref()) != precision_values(target);
1300 if precision_changes && batch_has_present_decimal(&batch) {
1301 anyhow::bail!(
1302 "cannot relabel precision metadata for a promotion group that contains decimal values"
1303 );
1304 }
1305
1306 Ok(RecordBatch::try_new(
1307 Arc::new(target.clone()),
1308 batch.columns().to_vec(),
1309 )?)
1310}
1311
1312fn concat_sorted(left: &RecordBatch, right: &RecordBatch) -> anyhow::Result<RecordBatch> {
1313 let schema = left.schema();
1314 let batch = concat_batches(&schema, [left, right])
1315 .map_err(|e| anyhow::anyhow!("Failed to concatenate promotion groups: {e}"))?;
1316 sort_by_ts_init(&batch)
1317}
1318
1319fn sort_by_ts_init(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1320 let ts_init = batch
1321 .schema()
1322 .index_of("ts_init")
1323 .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
1324 let original_row_index = batch
1325 .column_by_name(PROMOTION_ROW_INDEX)
1326 .cloned()
1327 .unwrap_or_else(|| Arc::new(UInt64Array::from_iter_values(0..batch.num_rows() as u64)));
1328
1329 let options = Some(SortOptions {
1330 descending: false,
1331 nulls_first: false,
1332 });
1333
1334 let indices = lexsort_to_indices(
1335 &[
1336 SortColumn {
1337 values: batch.column(ts_init).clone(),
1338 options,
1339 },
1340 SortColumn {
1341 values: original_row_index,
1342 options,
1343 },
1344 ],
1345 None,
1346 )
1347 .map_err(|e| anyhow::anyhow!("Failed to sort promotion group: {e}"))?;
1348
1349 take_record_batch(batch, &indices)
1350 .map_err(|e| anyhow::anyhow!("Failed to reorder promotion group: {e}"))
1351}
1352
1353fn metadata_without_precision(schema: &Schema) -> HashMap<String, String> {
1354 schema
1355 .metadata()
1356 .iter()
1357 .filter(|(key, _)| *key != "price_precision" && *key != "size_precision")
1358 .map(|(key, value)| (key.clone(), value.clone()))
1359 .collect()
1360}
1361
1362fn precision_values(schema: &Schema) -> (Option<&str>, Option<&str>) {
1363 (
1364 schema.metadata().get("price_precision").map(String::as_str),
1365 schema.metadata().get("size_precision").map(String::as_str),
1366 )
1367}
1368
1369fn is_precision_sentinel(schema: &Schema) -> bool {
1370 let (price, size) = precision_values(schema);
1371 (price.is_some() || size.is_some())
1372 && price.is_none_or(|value| value == "0")
1373 && size.is_none_or(|value| value == "0")
1374}
1375
1376fn batch_has_present_decimal(batch: &RecordBatch) -> bool {
1377 batch
1378 .columns()
1379 .iter()
1380 .any(|column| array_has_present_decimal(column.as_ref()))
1381}
1382
1383fn array_has_present_decimal(array: &dyn Array) -> bool {
1384 match array.data_type() {
1385 DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => {
1386 !array.is_empty() && array.null_count() < array.len()
1387 }
1388 DataType::List(_) => array
1390 .as_any()
1391 .downcast_ref::<ListArray>()
1392 .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1393 DataType::LargeList(_) => array
1394 .as_any()
1395 .downcast_ref::<LargeListArray>()
1396 .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1397 DataType::FixedSizeList(_, _) => array
1398 .as_any()
1399 .downcast_ref::<FixedSizeListArray>()
1400 .is_none_or(|list| array_has_present_decimal(list.values().as_ref())),
1401 DataType::Struct(_) => array
1402 .as_any()
1403 .downcast_ref::<StructArray>()
1404 .is_none_or(|values| {
1405 values
1406 .columns()
1407 .iter()
1408 .any(|column| array_has_present_decimal(column.as_ref()))
1409 }),
1410 _ => false,
1411 }
1412}
1413
1414fn list_values<O: OffsetSizeTrait>(list: &GenericListArray<O>) -> ArrayRef {
1415 let offsets = list.value_offsets();
1416 let start = offsets[0].as_usize();
1417 let end = offsets[offsets.len() - 1].as_usize();
1418 list.values().slice(start, end - start)
1419}
1420
1421#[cfg(test)]
1422mod promotion_benchmarks;
1423
1424#[cfg(test)]
1425mod promotion_group_tests {
1426 use std::{collections::HashMap, sync::Arc};
1427
1428 use datafusion::arrow::{
1429 array::{Array, Decimal128Array, ListArray, StringArray, UInt64Array},
1430 buffer::OffsetBuffer,
1431 datatypes::{DataType, Field, Schema},
1432 record_batch::RecordBatch,
1433 };
1434 use nautilus_common::enums::Environment;
1435 use nautilus_model::{
1436 data::{BookOrder, NautilusDataType, OrderBookDelta},
1437 enums::{BookAction, OrderSide},
1438 identifiers::InstrumentId,
1439 types::{Price, Quantity},
1440 };
1441 use nautilus_serialization::arrow::{EncodeToRecordBatch, KEY_IDENTIFIER};
1442 use rstest::rstest;
1443 use tempfile::TempDir;
1444
1445 use super::{
1446 array_has_present_decimal, normalize_clear_precision, split_record_batch_by_identifier,
1447 };
1448 use crate::{
1449 backend::parquet::{catalog::ParquetDataCatalog, io::read_parquet_from_object_store},
1450 common::storage::create_storage_backend_from_path,
1451 writer::{
1452 feather::{NAUTILUS_ARROW_METADATA_ID_COLUMN, NAUTILUS_ARROW_METADATA_JSON_COLUMN},
1453 run::FeatherSessionSource,
1454 },
1455 };
1456
1457 fn precision_batch(precision: &str, timestamps: Vec<u64>, price: Option<i128>) -> RecordBatch {
1458 let mut metadata = HashMap::new();
1459 metadata.insert("instrument_id".to_string(), "ETH/USDT.BINANCE".to_string());
1460 metadata.insert("price_precision".to_string(), precision.to_string());
1461 metadata.insert("size_precision".to_string(), "0".to_string());
1462 let price = Decimal128Array::from(vec![price; timestamps.len()])
1463 .with_precision_and_scale(38, 16)
1464 .unwrap();
1465 RecordBatch::try_new(
1466 Arc::new(Schema::new_with_metadata(
1467 vec![
1468 Field::new("ts_init", DataType::UInt64, false),
1469 Field::new("price", price.data_type().clone(), true),
1470 ],
1471 metadata,
1472 )),
1473 vec![Arc::new(UInt64Array::from(timestamps)), Arc::new(price)],
1474 )
1475 .unwrap()
1476 }
1477
1478 #[rstest]
1479 #[case::empty_row(0, false)]
1480 #[case::populated_row(1, true)]
1481 fn sliced_list_checks_only_its_rows_for_decimals(#[case] row: usize, #[case] expected: bool) {
1482 let price = Decimal128Array::from(vec![Some(123_i128)])
1483 .with_precision_and_scale(38, 16)
1484 .unwrap();
1485 let field = Arc::new(Field::new("item", price.data_type().clone(), false));
1486
1487 let list = ListArray::new(
1488 field,
1489 OffsetBuffer::new(vec![0_i32, 0, 1].into()),
1490 Arc::new(price),
1491 None,
1492 );
1493
1494 assert_eq!(array_has_present_decimal(&list.slice(row, 1)), expected);
1495 }
1496
1497 #[rstest]
1498 #[case::one_identifier(
1499 vec![Some("A"), None],
1500 vec![(Some("A"), vec![1]), (None, vec![2])],
1501 )]
1502 #[case::two_identifiers(
1503 vec![Some("A"), None, Some("B"), None],
1504 vec![(Some("A"), vec![1]), (Some("B"), vec![3]), (None, vec![2, 4])],
1505 )]
1506 fn split_by_identifier_keeps_rows_without_identifier_as_their_own_group(
1507 #[case] identifiers: Vec<Option<&str>>,
1508 #[case] expected: Vec<(Option<&str>, Vec<u64>)>,
1509 ) {
1510 let values = (1..=identifiers.len() as u64).collect::<Vec<_>>();
1511 let batch = RecordBatch::try_new(
1512 Arc::new(Schema::new(vec![
1513 Field::new("value", DataType::UInt64, false),
1514 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
1515 ])),
1516 vec![
1517 Arc::new(UInt64Array::from(values)),
1518 Arc::new(StringArray::from(identifiers)),
1519 ],
1520 )
1521 .unwrap();
1522
1523 let split = split_record_batch_by_identifier(&batch)
1524 .unwrap()
1525 .into_iter()
1526 .map(|(identifier, rows)| {
1527 let values = rows
1528 .column(0)
1529 .as_any()
1530 .downcast_ref::<UInt64Array>()
1531 .unwrap()
1532 .values()
1533 .to_vec();
1534 (identifier, values)
1535 })
1536 .collect::<Vec<_>>();
1537
1538 assert_eq!(
1539 split,
1540 expected
1541 .into_iter()
1542 .map(|(identifier, values)| (identifier.map(String::from), values))
1543 .collect::<Vec<_>>(),
1544 );
1545 }
1546
1547 #[rstest]
1548 fn clear_precision_keeps_instruments_separate() {
1549 let id_a = InstrumentId::from("AUD/USD.SIM");
1550 let id_b = InstrumentId::from("BTC/USD.SIM");
1551
1552 let add_a = OrderBookDelta::new(
1553 id_a,
1554 BookAction::Add,
1555 BookOrder::new(
1556 OrderSide::Buy,
1557 Price::from("1.23"),
1558 Quantity::from("4.500"),
1559 71,
1560 ),
1561 128,
1562 41,
1563 99.into(),
1564 100.into(),
1565 );
1566
1567 let add_b = OrderBookDelta::new(
1568 id_b,
1569 BookAction::Add,
1570 BookOrder::new(
1571 OrderSide::Sell,
1572 Price::from("12.3456"),
1573 Quantity::from("7.89000"),
1574 72,
1575 ),
1576 128,
1577 42,
1578 199.into(),
1579 200.into(),
1580 );
1581 let deltas = [
1582 OrderBookDelta::clear(id_a, 41, 99.into(), 100.into()),
1583 OrderBookDelta::clear(id_b, 42, 199.into(), 200.into()),
1584 add_a,
1585 add_b,
1586 ];
1587
1588 let mut batches = deltas
1589 .iter()
1590 .map(|delta| {
1591 (
1592 Some(delta.instrument_id.to_string()),
1593 OrderBookDelta::encode_batch(&delta.metadata(), &[*delta]).unwrap(),
1594 )
1595 })
1596 .collect::<Vec<_>>();
1597
1598 let original = batches.clone();
1599
1600 normalize_clear_precision(&mut batches).unwrap();
1601
1602 for ((identifier, batch), (original_identifier, original_batch)) in
1603 batches.iter().zip(&original)
1604 {
1605 let expected_schema = if *identifier == Some(id_a.to_string()) {
1606 original[2].1.schema()
1607 } else {
1608 original[3].1.schema()
1609 };
1610
1611 assert_eq!(identifier, original_identifier);
1612 assert_eq!(batch.schema(), expected_schema);
1613 assert_eq!(batch.columns(), original_batch.columns());
1614 }
1615 }
1616
1617 #[rstest]
1618 #[case::clear_only(false)]
1619 #[case::empty_following_batch(true)]
1620 fn clear_precision_without_following_orders(#[case] populated: bool) {
1621 let id = InstrumentId::from("AUD/USD.SIM");
1622 let clear = OrderBookDelta::clear(id, 41, 99.into(), 100.into());
1623 let clear_batch = OrderBookDelta::encode_batch(&clear.metadata(), &[clear]).unwrap();
1624
1625 let add = OrderBookDelta::new(
1626 id,
1627 BookAction::Add,
1628 BookOrder::new(
1629 OrderSide::Buy,
1630 Price::from("1.23"),
1631 Quantity::from("4.500"),
1632 71,
1633 ),
1634 128,
1635 40,
1636 49.into(),
1637 50.into(),
1638 );
1639 let add_batch = OrderBookDelta::encode_batch(&add.metadata(), &[add]).unwrap();
1640 let mut batches = Vec::new();
1641 if populated {
1642 batches.push((Some(id.to_string()), add_batch.clone()));
1643 }
1644
1645 batches.push((Some(id.to_string()), clear_batch.clone()));
1646 let clear_index = batches.len() - 1;
1647
1648 if populated {
1649 let mut metadata = add.metadata();
1650 metadata.insert("price_precision".to_string(), "4".to_string());
1651 let empty = RecordBatch::new_empty(Arc::new(
1652 add_batch.schema().as_ref().clone().with_metadata(metadata),
1653 ));
1654 batches.push((Some(id.to_string()), empty));
1655 }
1656
1657 normalize_clear_precision(&mut batches).unwrap();
1658
1659 let expected_schema = if populated {
1660 add_batch.schema()
1661 } else {
1662 clear_batch.schema()
1663 };
1664
1665 assert_eq!(batches[clear_index].1.schema(), expected_schema);
1666 assert_eq!(batches[clear_index].1.columns(), clear_batch.columns());
1667 assert_eq!(batches.len(), if populated { 3 } else { 1 });
1668 }
1669
1670 #[rstest]
1671 fn overlapping_empty_precision_group_is_promoted() {
1672 let temp = TempDir::new().unwrap();
1673 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1674 let batches = vec![
1675 precision_batch("0", vec![1, 3], None),
1676 precision_batch("2", vec![2], Some(20_000_000_000_000_000)),
1677 ];
1678
1679 catalog
1680 .convert_feather_batches_to_parquet(
1681 Environment::Backtest,
1682 "run-1",
1683 &NautilusDataType::QuoteTick.into(),
1684 "backtest/run-1/quotes_1.feather",
1685 &batches,
1686 false,
1687 Some("replay"),
1688 )
1689 .unwrap();
1690 catalog
1691 .convert_feather_batches_to_parquet(
1692 Environment::Backtest,
1693 "run-1",
1694 &NautilusDataType::QuoteTick.into(),
1695 "backtest/run-1/quotes_1.feather",
1696 &batches,
1697 false,
1698 Some("replay"),
1699 )
1700 .unwrap();
1701
1702 assert_eq!(
1703 catalog
1704 .get_intervals(
1705 &NautilusDataType::QuoteTick.into(),
1706 Some("ETH/USDT.BINANCE")
1707 )
1708 .unwrap(),
1709 vec![(1, 3)],
1710 );
1711 }
1712
1713 #[rstest]
1714 fn promotion_rejects_reserved_row_index() {
1715 let temp = TempDir::new().unwrap();
1716 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1717 let batch = precision_batch("2", vec![1], Some(1));
1718 let mut fields = batch.schema().fields().to_vec();
1719 fields.push(Arc::new(Field::new(
1720 super::PROMOTION_ROW_INDEX,
1721 DataType::UInt64,
1722 false,
1723 )));
1724 let mut columns = batch.columns().to_vec();
1725 columns.push(Arc::new(UInt64Array::from(vec![19])));
1726 let batch = RecordBatch::try_new(
1727 Arc::new(Schema::new_with_metadata(
1728 fields,
1729 batch.schema().metadata().clone(),
1730 )),
1731 columns,
1732 )
1733 .unwrap();
1734
1735 let error = catalog
1736 .convert_feather_batches_to_parquet(
1737 Environment::Backtest,
1738 "run-1",
1739 &NautilusDataType::QuoteTick.into(),
1740 "backtest/run-1/quotes_1.feather",
1741 &[batch],
1742 false,
1743 Some("replay"),
1744 )
1745 .unwrap_err();
1746
1747 assert_eq!(
1748 error.to_string(),
1749 "Feather source uses reserved column nautilus_promotion_row_index"
1750 );
1751 assert_eq!(
1752 catalog
1753 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1754 .unwrap(),
1755 Vec::<String>::new()
1756 );
1757 }
1758
1759 #[rstest]
1760 fn overlapping_incompatible_groups_write_nothing() {
1761 let temp = TempDir::new().unwrap();
1762 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1763 let batches = vec![
1764 precision_batch("2", vec![1, 3], Some(1)),
1765 precision_batch("5", vec![2], Some(2)),
1766 ];
1767
1768 let error = catalog
1769 .convert_feather_batches_to_parquet(
1770 Environment::Backtest,
1771 "run-1",
1772 &NautilusDataType::QuoteTick.into(),
1773 "backtest/run-1/quotes_1.feather",
1774 &batches,
1775 false,
1776 Some("replay"),
1777 )
1778 .unwrap_err();
1779 assert_eq!(
1780 error.to_string(),
1781 "overlapping promotion groups have incompatible precision metadata"
1782 );
1783 assert!(
1784 catalog
1785 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1786 .unwrap()
1787 .is_empty()
1788 );
1789 }
1790
1791 #[rstest]
1792 fn overlapping_groups_keep_source_order_for_equal_ts_init(
1793 #[values(false, true)] repeated_schema: bool,
1794 ) {
1795 let temp = TempDir::new().unwrap();
1796 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1797 let mut batches = vec![
1798 precision_batch("2", vec![1, 2], Some(10)),
1799 precision_batch("0", vec![2, 3], None),
1800 ];
1801
1802 if repeated_schema {
1803 batches.push(precision_batch("2", vec![2, 4], Some(30)));
1804 }
1805
1806 catalog
1807 .convert_feather_batches_to_parquet(
1808 Environment::Backtest,
1809 "run-1",
1810 &NautilusDataType::QuoteTick.into(),
1811 "backtest/run-1/quotes_1.feather",
1812 &batches,
1813 false,
1814 Some("replay"),
1815 )
1816 .unwrap();
1817
1818 let files = catalog
1819 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1820 .unwrap();
1821
1822 let (written, _) = catalog
1823 .execute_async(|| async {
1824 read_parquet_from_object_store(
1825 catalog.object_store.clone(),
1826 &object_store::path::Path::from(files[0].as_str()),
1827 )
1828 .await
1829 })
1830 .unwrap();
1831
1832 assert!(
1833 written
1834 .iter()
1835 .all(|batch| batch.column_by_name(super::PROMOTION_ROW_INDEX).is_none())
1836 );
1837
1838 let rows = written
1839 .iter()
1840 .flat_map(|batch| {
1841 let ts_init = batch
1842 .column_by_name("ts_init")
1843 .unwrap()
1844 .as_any()
1845 .downcast_ref::<UInt64Array>()
1846 .unwrap()
1847 .values()
1848 .to_vec();
1849 let price = batch
1850 .column_by_name("price")
1851 .unwrap()
1852 .as_any()
1853 .downcast_ref::<Decimal128Array>()
1854 .unwrap()
1855 .iter()
1856 .collect::<Vec<_>>();
1857 ts_init.into_iter().zip(price)
1858 })
1859 .collect::<Vec<_>>();
1860
1861 assert_eq!(files.len(), 1);
1862
1863 let expected = if repeated_schema {
1864 vec![
1865 (1, Some(10)),
1866 (2, Some(10)),
1867 (2, None),
1868 (2, Some(30)),
1869 (3, None),
1870 (4, Some(30)),
1871 ]
1872 } else {
1873 vec![(1, Some(10)), (2, Some(10)), (2, None), (3, None)]
1874 };
1875
1876 assert_eq!(rows, expected);
1877 }
1878
1879 #[rstest]
1880 fn promote_feather_file_skips_staged_batches_without_rows() {
1881 let temp = TempDir::new().unwrap();
1882 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1883 let storage =
1884 create_storage_backend_from_path(temp.path().to_str().unwrap(), None).unwrap();
1885 let source = FeatherSessionSource::new(storage, Environment::Backtest, "run-1");
1886 let staged = RecordBatch::new_empty(Arc::new(Schema::new(vec![
1887 Field::new("ts_init", DataType::UInt64, false),
1888 Field::new(NAUTILUS_ARROW_METADATA_ID_COLUMN, DataType::Utf8, false),
1889 Field::new(NAUTILUS_ARROW_METADATA_JSON_COLUMN, DataType::Utf8, false),
1890 ])));
1891
1892 let summary = catalog
1893 .promote_feather_file(
1894 &source,
1895 "backtest/run-1/quotes_1.feather",
1896 vec![staged],
1897 false,
1898 "replay",
1899 )
1900 .unwrap();
1901
1902 assert!(summary.is_none());
1903 assert!(
1904 catalog
1905 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1906 .unwrap()
1907 .is_empty()
1908 );
1909 }
1910
1911 #[rstest]
1912 fn overlapping_groups_with_different_fields_write_nothing() {
1913 let temp = TempDir::new().unwrap();
1914 let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1915 let nullable = precision_batch("2", vec![2], Some(2));
1916 let schema = nullable.schema();
1917 let non_nullable = RecordBatch::try_new(
1918 Arc::new(Schema::new_with_metadata(
1919 vec![
1920 schema.field(0).clone(),
1921 schema.field(1).clone().with_nullable(false),
1922 ],
1923 schema.metadata().clone(),
1924 )),
1925 nullable.columns().to_vec(),
1926 )
1927 .unwrap();
1928 let batches = vec![precision_batch("2", vec![1, 3], Some(1)), non_nullable];
1929
1930 let error = catalog
1931 .convert_feather_batches_to_parquet(
1932 Environment::Backtest,
1933 "run-1",
1934 &NautilusDataType::QuoteTick.into(),
1935 "backtest/run-1/quotes_1.feather",
1936 &batches,
1937 false,
1938 Some("replay"),
1939 )
1940 .unwrap_err();
1941
1942 assert_eq!(
1943 error.to_string(),
1944 "overlapping promotion groups have incompatible schemas"
1945 );
1946 assert!(
1947 catalog
1948 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1949 .unwrap()
1950 .is_empty()
1951 );
1952 }
1953}
1954
1955#[cfg(test)]
1956mod stream_folder_tests {
1957 use nautilus_common::enums::Environment;
1958 use nautilus_model::data::NautilusDataType;
1959 use rstest::rstest;
1960
1961 use crate::{
1962 catalog::types::CatalogDataType, common::paths::catalog_data_type_from_session_feather_path,
1963 };
1964
1965 #[rstest]
1966 #[case("quotes", NautilusDataType::QuoteTick)]
1967 #[case("quote_tick", NautilusDataType::QuoteTick)]
1968 #[case("trades", NautilusDataType::TradeTick)]
1969 #[case("trade_tick", NautilusDataType::TradeTick)]
1970 #[case("bars", NautilusDataType::Bar)]
1971 #[case("bar", NautilusDataType::Bar)]
1972 #[case("order_book_deltas", NautilusDataType::OrderBookDelta)]
1973 #[case("order_book_delta", NautilusDataType::OrderBookDelta)]
1974 #[case("mark_prices", NautilusDataType::MarkPriceUpdate)]
1975 #[case("mark_price_update", NautilusDataType::MarkPriceUpdate)]
1976 #[case("index_prices", NautilusDataType::IndexPriceUpdate)]
1977 #[case("index_price_update", NautilusDataType::IndexPriceUpdate)]
1978 #[case("funding_rates", NautilusDataType::FundingRateUpdate)]
1979 #[case("funding_rate_update", NautilusDataType::FundingRateUpdate)]
1980 #[case("instrument_closes", NautilusDataType::InstrumentClose)]
1981 #[case("instrument_close", NautilusDataType::InstrumentClose)]
1982 fn stream_folders_parse_to_their_data_type(
1983 #[case] folder: &str,
1984 #[case] expected: NautilusDataType,
1985 ) {
1986 let path = format!("backtest/run-1/{folder}/{folder}_0.feather");
1987
1988 assert_eq!(
1989 catalog_data_type_from_session_feather_path(&path, Environment::Backtest, "run-1")
1990 .unwrap(),
1991 CatalogDataType::from(expected),
1992 );
1993 }
1994}
1995
1996#[cfg(test)]
1997mod read_run_tests {
1998 use std::{
1999 fs::{self, OpenOptions},
2000 sync::{Arc, atomic::AtomicU64},
2001 };
2002
2003 use nautilus_common::enums::Environment;
2004 use nautilus_core::UnixNanos;
2005 use nautilus_model::data::{
2006 Data, NautilusDataType, QuoteTick, TradeTick,
2007 stubs::{quote_audusd, quote_ethusdt_binance, stub_trade_ethusdt_buy},
2008 };
2009 use rstest::rstest;
2010 use tempfile::TempDir;
2011
2012 use crate::{
2013 backend::parquet::catalog::ParquetDataCatalog,
2014 catalog::types::CatalogDataType,
2015 writer::feather::{FEATHER_PARTIAL_EXTENSION, FeatherWriter, RotationConfig, WriterClock},
2016 };
2017
2018 fn quote_btc(ts: u64) -> QuoteTick {
2019 QuoteTick {
2020 instrument_id: "BTCUSDT-PERP.BINANCE".into(),
2021 ts_event: UnixNanos::from(ts),
2022 ts_init: UnixNanos::from(ts),
2023 ..quote_ethusdt_binance()
2024 }
2025 }
2026
2027 fn quote_aud(ts: u64) -> QuoteTick {
2028 QuoteTick {
2029 ts_event: UnixNanos::from(ts),
2030 ts_init: UnixNanos::from(ts),
2031 ..quote_audusd()
2032 }
2033 }
2034
2035 fn quote_eth(ts: u64) -> QuoteTick {
2036 QuoteTick {
2037 ts_event: UnixNanos::from(ts),
2038 ts_init: UnixNanos::from(ts),
2039 ..quote_ethusdt_binance()
2040 }
2041 }
2042
2043 fn trade_eth(ts: u64) -> TradeTick {
2044 TradeTick {
2045 ts_event: UnixNanos::from(ts),
2046 ts_init: UnixNanos::from(ts),
2047 ..stub_trade_ethusdt_buy()
2048 }
2049 }
2050
2051 fn run_writer(temp_dir: &TempDir) -> FeatherWriter {
2052 FeatherWriter::new(
2053 temp_dir.path().join("backtest").join("run-001"),
2054 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2055 RotationConfig::NoRotation,
2056 None,
2057 None,
2058 )
2059 }
2060
2061 fn read_backtest(temp_dir: &TempDir) -> Vec<Data> {
2062 ParquetDataCatalog::new(temp_dir.path(), None, None, None, None)
2063 .read_backtest("run-001")
2064 .unwrap()
2065 }
2066
2067 fn write_crashed_run(temp_dir: &TempDir, quotes: &[QuoteTick]) {
2070 let mut writer = run_writer(temp_dir);
2071
2072 for quote in quotes {
2073 writer.write_data(Data::Quote(*quote)).unwrap();
2074 writer.flush().unwrap();
2075 }
2076
2077 writer.close().unwrap();
2078
2079 let sealed = temp_dir
2080 .path()
2081 .join("backtest/run-001/quotes/quotes_0.feather");
2082 let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
2083 fs::rename(&sealed, &partial).unwrap();
2084
2085 let file = OpenOptions::new().write(true).open(&partial).unwrap();
2087 file.set_len(file.metadata().unwrap().len() - 9).unwrap();
2088 }
2089
2090 #[rstest]
2091 #[case::backtest(ParquetDataCatalog::read_backtest)]
2092 #[case::live(ParquetDataCatalog::read_live_run)]
2093 fn read_run_rejects_path_bearing_ids(
2094 #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2095 #[values(
2096 "",
2097 ".",
2098 "..",
2099 "../run",
2100 "run/child",
2101 "run\\child",
2102 "/run",
2103 "\\run",
2104 "C:run",
2105 "C:/run",
2106 "C:\\run",
2107 "\\\\server\\share",
2108 "run:stream",
2109 "run\0id"
2110 )]
2111 instance_id: &str,
2112 ) {
2113 let temp_dir = TempDir::new().unwrap();
2114 let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2115
2116 let e = reader(&catalog, instance_id).unwrap_err();
2117
2118 assert_eq!(
2119 e.to_string(),
2120 format!("Invalid run instance ID {instance_id:?}: expected a single path component"),
2121 );
2122 }
2123
2124 #[rstest]
2125 #[case::backtest(ParquetDataCatalog::read_backtest)]
2126 #[case::live(ParquetDataCatalog::read_live_run)]
2127 fn read_run_rejects_external_paths_before_recovery(
2128 #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2129 #[values(false, true)] absolute: bool,
2130 ) {
2131 let temp_dir = TempDir::new().unwrap();
2132 let outside = TempDir::new_in(temp_dir.path()).unwrap();
2133 let catalog_path = temp_dir.path().join("catalog");
2134 fs::create_dir_all(catalog_path.join("backtest")).unwrap();
2135 fs::create_dir_all(catalog_path.join("live")).unwrap();
2136 write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2137 let directory = outside.path().join("backtest/run-001");
2138 let partial = directory.join("quotes/quotes_0.feather.partial");
2139 let original = fs::read(&partial).unwrap();
2140
2141 let instance_id = if absolute {
2142 directory.to_str().unwrap().to_string()
2143 } else {
2144 format!(
2145 "../../{}/backtest/run-001",
2146 outside.path().file_name().unwrap().to_str().unwrap(),
2147 )
2148 };
2149
2150 let catalog = ParquetDataCatalog::new(&catalog_path, None, None, None, None);
2151
2152 let result = reader(&catalog, &instance_id);
2153
2154 assert!(result.is_err());
2155 assert_eq!(fs::read(&partial).unwrap(), original);
2156 assert!(!partial.with_extension("").exists());
2157 }
2158
2159 #[rstest]
2160 #[case::backtest(ParquetDataCatalog::read_backtest)]
2161 #[case::live(ParquetDataCatalog::read_live_run)]
2162 fn read_run_returns_no_data_for_missing_directories(
2163 #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2164 #[values("", "backtest", "backtest/run-001")] directory: &str,
2165 ) {
2166 let temp_dir = TempDir::new().unwrap();
2167 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2168 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2169 fs::remove_dir_all(temp_dir.path().join(directory)).unwrap();
2170
2171 let data = reader(&catalog, "run-001").unwrap();
2172 let conversion = catalog.convert_stream_to_data(
2173 "run-001",
2174 &CatalogDataType::from(NautilusDataType::QuoteTick),
2175 Environment::Backtest,
2176 None,
2177 false,
2178 );
2179
2180 assert_eq!(data, Vec::<Data>::new());
2181 conversion.unwrap();
2182 assert!(!temp_dir.path().join(directory).exists());
2183 }
2184
2185 #[rstest]
2186 fn convert_stream_to_data_rejects_path_bearing_ids() {
2187 let temp_dir = TempDir::new().unwrap();
2188 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2189
2190 let e = catalog
2191 .convert_stream_to_data(
2192 "../run-001",
2193 &CatalogDataType::from(NautilusDataType::QuoteTick),
2194 Environment::Backtest,
2195 None,
2196 false,
2197 )
2198 .unwrap_err();
2199
2200 assert_eq!(
2201 e.to_string(),
2202 "Invalid run instance ID \"../run-001\": expected a single path component",
2203 );
2204 }
2205
2206 #[rstest]
2207 #[cfg(unix)]
2208 #[case::backtest(ParquetDataCatalog::read_backtest, "backtest")]
2209 #[case::live(ParquetDataCatalog::read_live_run, "live")]
2210 fn read_run_rejects_escaping_directory_symlinks(
2211 #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2212 #[case] environment: &str,
2213 #[values(false, true)] environment_symlink: bool,
2214 ) {
2215 let temp_dir = TempDir::new().unwrap();
2216 let outside = TempDir::new().unwrap();
2217 write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2218 let run = outside.path().join("backtest/run-001");
2219 let partial = run.join("quotes/quotes_0.feather.partial");
2220 let original = fs::read(&partial).unwrap();
2221 let root = temp_dir.path().join(environment);
2222 if environment_symlink {
2223 std::os::unix::fs::symlink(outside.path().join("backtest"), &root).unwrap();
2224 } else {
2225 fs::create_dir(&root).unwrap();
2226 std::os::unix::fs::symlink(&run, root.join("run-001")).unwrap();
2227 }
2228
2229 let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2230 let result = reader(&catalog, "run-001");
2231
2232 assert!(result.is_err());
2233 assert_eq!(fs::read(&partial).unwrap(), original);
2234 assert!(!partial.with_extension("").exists());
2235 }
2236
2237 #[rstest]
2238 #[cfg(unix)]
2239 fn read_run_rejects_directory_symlinks_to_parent_roots(
2240 #[values(false, true)] environment_symlink: bool,
2241 ) {
2242 let temp_dir = TempDir::new().unwrap();
2243 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2244 let partial = temp_dir
2245 .path()
2246 .join("backtest/run-001/quotes/quotes_0.feather.partial");
2247 let original = fs::read(&partial).unwrap();
2248 let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2249 let result = if environment_symlink {
2250 std::os::unix::fs::symlink(temp_dir.path(), temp_dir.path().join("live")).unwrap();
2251 catalog.read_live_run("backtest")
2252 } else {
2253 let root = temp_dir.path().join("backtest");
2254 std::os::unix::fs::symlink(&root, root.join("run-alias")).unwrap();
2255 catalog.read_backtest("run-alias")
2256 };
2257
2258 assert!(result.is_err());
2259 assert_eq!(fs::read(&partial).unwrap(), original);
2260 assert!(!partial.with_extension("").exists());
2261 }
2262
2263 #[rstest]
2264 #[cfg(unix)]
2265 #[case::backtest(ParquetDataCatalog::read_backtest, "backtest")]
2266 #[case::live(ParquetDataCatalog::read_live_run, "live")]
2267 fn read_run_leaves_symlinked_recovery_targets_untouched(
2268 #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2269 #[case] environment: &str,
2270 #[values(false, true)] directory_symlink: bool,
2271 ) {
2272 let temp_dir = TempDir::new().unwrap();
2273 let outside = TempDir::new().unwrap();
2274 write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2275 let partial = outside
2276 .path()
2277 .join("backtest/run-001/quotes/quotes_0.feather.partial");
2278 let original = fs::read(&partial).unwrap();
2279 let run = temp_dir.path().join(environment).join("run-001");
2280 fs::create_dir_all(&run).unwrap();
2281 let link = if directory_symlink {
2282 let link = run.join("quotes");
2283 std::os::unix::fs::symlink(partial.parent().unwrap(), &link).unwrap();
2284 link
2285 } else {
2286 fs::create_dir(run.join("quotes")).unwrap();
2287 let link = run.join("quotes/quotes_0.feather.partial");
2288 std::os::unix::fs::symlink(&partial, &link).unwrap();
2289 link
2290 };
2291
2292 let target = fs::read_link(&link).unwrap();
2293 let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2294
2295 let data = reader(&catalog, "run-001").unwrap();
2296
2297 assert_eq!(data, Vec::<Data>::new());
2298 assert_eq!(fs::read(&partial).unwrap(), original);
2299 assert_eq!(fs::read_link(&link).unwrap(), target);
2300 assert!(!partial.with_extension("").exists());
2301 assert!(!run.join("quotes/quotes_0.feather").exists());
2302 }
2303
2304 #[rstest]
2305 #[cfg(unix)]
2306 fn read_run_recovers_in_root_directory_symlinks(
2307 #[values(false, true)] environment_symlink: bool,
2308 ) {
2309 let temp_dir = TempDir::new().unwrap();
2310 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2311 let run = temp_dir.path().join("backtest/run-001");
2312
2313 if environment_symlink {
2314 std::os::unix::fs::symlink(
2315 temp_dir.path().join("backtest"),
2316 temp_dir.path().join("live"),
2317 )
2318 .unwrap();
2319 } else {
2320 std::os::unix::fs::symlink(&run, temp_dir.path().join("backtest/run-alias")).unwrap();
2321 }
2322
2323 let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2324 let data = if environment_symlink {
2325 catalog.read_live_run("run-001").unwrap()
2326 } else {
2327 catalog.read_backtest("run-alias").unwrap()
2328 };
2329
2330 assert_eq!(data, vec![Data::Quote(quote_aud(1_000))]);
2331 assert!(!run.join("quotes/quotes_0.feather.partial").exists());
2332 assert!(run.join("quotes/quotes_0.feather").exists());
2333 }
2334
2335 #[rstest]
2336 fn read_backtest_returns_every_record_sorted_by_ts_init() {
2337 let temp_dir = TempDir::new().unwrap();
2338 let mut writer = run_writer(&temp_dir);
2339
2340 for data in [
2341 Data::Quote(quote_eth(3_000)),
2342 Data::Quote(quote_aud(1_000)),
2343 Data::Trade(trade_eth(2_000)),
2344 Data::Quote(quote_aud(4_000)),
2345 Data::Quote(quote_btc(5_000)),
2346 ] {
2347 writer.write_data(data).unwrap();
2348 }
2349
2350 writer.close().unwrap();
2351
2352 assert_eq!(
2353 read_backtest(&temp_dir),
2354 vec![
2355 Data::Quote(quote_aud(1_000)),
2356 Data::Trade(trade_eth(2_000)),
2357 Data::Quote(quote_eth(3_000)),
2358 Data::Quote(quote_aud(4_000)),
2359 Data::Quote(quote_btc(5_000)),
2360 ],
2361 );
2362 }
2363
2364 #[rstest]
2365 #[case::selected_identifier_first(true)]
2366 #[case::every_identifier_first(false)]
2367 fn convert_stream_to_data_repeats_across_identifier_selections(#[case] selected_first: bool) {
2368 let temp_dir = TempDir::new().unwrap();
2369 let mut writer = run_writer(&temp_dir);
2370 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2371 writer.write_data(Data::Quote(quote_eth(2_000))).unwrap();
2372 writer.close().unwrap();
2373
2374 let selected = [quote_eth(2_000).instrument_id.to_string()];
2376
2377 let conversions = if selected_first {
2378 [Some(&selected[..]), None]
2379 } else {
2380 [None, Some(&selected[..])]
2381 };
2382
2383 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2384
2385 for identifiers in conversions {
2386 catalog
2387 .convert_stream_to_data(
2388 "run-001",
2389 &CatalogDataType::from(NautilusDataType::QuoteTick),
2390 Environment::Backtest,
2391 identifiers,
2392 false,
2393 )
2394 .unwrap();
2395 }
2396
2397 assert_eq!(
2398 catalog
2399 .query::<QuoteTick>(None, None, None, None, None, true)
2400 .unwrap(),
2401 vec![quote_aud(1_000), quote_eth(2_000)],
2402 );
2403 }
2404
2405 #[rstest]
2406 fn read_backtest_skips_open_partial_files() {
2407 let temp_dir = TempDir::new().unwrap();
2408 let mut writer = run_writer(&temp_dir);
2409 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2410 writer.flush().unwrap();
2411
2412 let data = read_backtest(&temp_dir);
2413 writer.close().unwrap();
2414
2415 assert_eq!(data, Vec::<Data>::new());
2416 assert_eq!(
2417 read_backtest(&temp_dir),
2418 vec![Data::Quote(quote_aud(1_000))]
2419 );
2420 }
2421
2422 #[rstest]
2423 fn read_backtest_recovers_crashed_partial_files() {
2424 let temp_dir = TempDir::new().unwrap();
2425 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2426
2427 assert_eq!(
2428 read_backtest(&temp_dir),
2429 vec![Data::Quote(quote_aud(1_000))]
2430 );
2431 }
2432
2433 #[rstest]
2434 fn convert_stream_to_data_recovers_crashed_partial_files() {
2435 let temp_dir = TempDir::new().unwrap();
2436 write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2437 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2438
2439 catalog
2440 .convert_stream_to_data(
2441 "run-001",
2442 &CatalogDataType::from(NautilusDataType::QuoteTick),
2443 Environment::Backtest,
2444 None,
2445 false,
2446 )
2447 .unwrap();
2448
2449 assert_eq!(
2450 catalog
2451 .query::<QuoteTick>(None, None, None, None, None, true)
2452 .unwrap(),
2453 vec![quote_aud(1_000)],
2454 );
2455 }
2456
2457 #[rstest]
2458 #[cfg(feature = "python")]
2459 fn read_backtest_reads_custom_data() {
2460 use nautilus_model::{
2461 data::{CustomData, DataType},
2462 identifiers::InstrumentId,
2463 };
2464 use nautilus_serialization::ensure_custom_data_registered;
2465
2466 use crate::test_data::RustTestCustomData;
2467
2468 ensure_custom_data_registered::<RustTestCustomData>();
2469 let instrument_id = InstrumentId::from("RUST.TEST");
2470
2471 let custom = Data::Custom(CustomData::new(
2472 Arc::new(RustTestCustomData {
2473 instrument_id,
2474 value: 1.23,
2475 flag: true,
2476 ts_event: UnixNanos::from(5_000),
2477 ts_init: UnixNanos::from(5_000),
2478 }),
2479 DataType::new("RustTestCustomData", None, Some(instrument_id.to_string())),
2480 ));
2481
2482 let temp_dir = TempDir::new().unwrap();
2483 let mut writer = run_writer(&temp_dir);
2484 writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2485 writer.write_data(custom.clone()).unwrap();
2486 writer.close().unwrap();
2487
2488 assert_eq!(
2489 read_backtest(&temp_dir),
2490 vec![Data::Quote(quote_aud(1_000)), custom]
2491 );
2492 }
2493}