1#![expect(
22 clippy::unused_self,
23 reason = "session registration keeps backend-specific ordering logic together"
24)]
25
26use std::{borrow::Cow, sync::Arc};
27
28use datafusion::arrow::{datatypes::Schema, record_batch::RecordBatch};
29use futures::StreamExt;
30use indexmap::IndexMap;
31use nautilus_core::UnixNanos;
32use nautilus_model::data::{
33 Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate,
34 OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose,
35 to_variant,
36};
37use nautilus_serialization::arrow::{
38 DecodeDataFromRecordBatch, DecodeTypedFromRecordBatch, U64ColumnRef,
39};
40use object_store::path::Path as ObjectPath;
41
42use crate::{
43 backend::parquet::{
44 catalog::ParquetDataCatalog,
45 paths::{make_object_store_path, urisafe_instrument_id},
46 },
47 catalog::types::{CatalogDataType, parquet_data_path_prefix, record_path_prefix},
48 common::{
49 conversion::FeatherConversionSummary,
50 custom::decode_custom_batches_to_data,
51 datafusion::identifiers_from_record_batches,
52 paths::{identifier_from_session_feather_path, type_name_from_session_feather_path},
53 },
54 writer::{
55 materializer::{
56 StreamConversionOptions, apply_stream_conversion_transform,
57 coalesce_stream_conversion_batches, read_feather_record_batches,
58 restore_staged_record_batches,
59 },
60 run::FeatherSessionSource,
61 },
62};
63
64impl ParquetDataCatalog {
65 pub(crate) fn promote_feather_file(
66 &self,
67 source: &FeatherSessionSource,
68 feather_path: &str,
69 batches: Vec<RecordBatch>,
70 use_ts_event_for_ts_init: bool,
71 replay_identity: &str,
72 ) -> anyhow::Result<Option<FeatherConversionSummary>> {
73 if batches.is_empty() {
74 return Ok(None);
75 }
76 let batches = Self::restore_staged_batches(batches)?;
77 let type_name =
78 type_name_from_session_feather_path(feather_path, &source.kind, &source.instance_id)?;
79 let catalog_data_name = Self::canonical_stream_data_name(&type_name);
80 anyhow::ensure!(
81 Self::is_supported_stream_data_type(catalog_data_name),
82 "Unknown data class: {type_name}"
83 );
84 let identifier = Self::identifier_from_batch_or_path(
85 &batches[0],
86 feather_path,
87 &source.kind,
88 &source.instance_id,
89 )
90 .filter(|identifier| {
91 batches.iter().all(|batch| {
92 Self::identifier_from_batch_or_path(
93 batch,
94 feather_path,
95 &source.kind,
96 &source.instance_id,
97 )
98 .as_ref()
99 == Some(identifier)
100 })
101 });
102
103 self.convert_feather_batches_to_parquet(
104 &source.kind,
105 &source.instance_id,
106 catalog_data_name,
107 feather_path,
108 &batches,
109 use_ts_event_for_ts_init,
110 Some(replay_identity),
111 )?;
112 Ok(Some(FeatherConversionSummary {
113 type_name,
114 identifier,
115 feather_path: feather_path.to_string(),
116 native_version: None,
117 unmatched_identifiers: None,
118 }))
119 }
120
121 pub fn read_live_run(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
171 self.read_run_data("live", instance_id)
172 }
173
174 pub fn read_backtest(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
216 self.read_run_data("backtest", instance_id)
217 }
218
219 fn read_run_data(&self, subdirectory: &str, instance_id: &str) -> anyhow::Result<Vec<Data>> {
234 let data_types = self.list_directory_stems(&format!("{subdirectory}/{instance_id}"))?;
236
237 if data_types.is_empty() {
238 return Ok(Vec::new());
240 }
241
242 let mut all_data: Vec<Data> = Vec::new();
243
244 for data_cls in data_types {
246 let feather_files = self.list_feather_files(
248 subdirectory,
249 instance_id,
250 &data_cls,
251 None, )?;
253
254 if feather_files.is_empty() {
255 continue; }
257
258 for file_path in feather_files {
260 let batches = self.read_feather_file(&file_path)?;
262
263 if batches.is_empty() {
264 continue; }
266
267 let decode_data_cls = Self::canonical_stream_data_name(&data_cls);
268
269 let file_data: Vec<Data> = match decode_data_cls {
271 "quotes" => {
272 let quotes: Vec<QuoteTick> =
273 self.convert_record_batches_to_data(batches, false)?;
274 quotes.into_iter().map(Data::from).collect()
275 }
276 "trades" => {
277 let trades: Vec<TradeTick> =
278 self.convert_record_batches_to_data(batches, false)?;
279 trades.into_iter().map(Data::from).collect()
280 }
281 "order_book_deltas" => {
282 let deltas: Vec<OrderBookDelta> =
283 self.convert_record_batches_to_data(batches, false)?;
284 deltas.into_iter().map(Data::from).collect()
285 }
286 "order_book_depths" => {
287 let depths: Vec<OrderBookDepth> =
288 self.convert_record_batches_to_data(batches, false)?;
289 depths.into_iter().map(Data::from).collect()
290 }
291 "bars" => {
292 let bars: Vec<Bar> = self.convert_record_batches_to_data(batches, false)?;
293 bars.into_iter().map(Data::from).collect()
294 }
295 "index_prices" => {
296 let prices: Vec<IndexPriceUpdate> =
297 self.convert_record_batches_to_data(batches, false)?;
298 prices.into_iter().map(Data::from).collect()
299 }
300 "mark_prices" => {
301 let prices: Vec<MarkPriceUpdate> =
302 self.convert_record_batches_to_data(batches, false)?;
303 prices.into_iter().map(Data::from).collect()
304 }
305 "option_greeks" => {
306 let greeks: Vec<OptionGreeks> =
307 self.convert_record_batches_to_data(batches, false)?;
308 greeks.into_iter().map(Data::from).collect()
309 }
310 "funding_rates" => {
311 let funding_rates: Vec<FundingRateUpdate> =
312 self.convert_record_batches_to_data(batches, false)?;
313 funding_rates.into_iter().map(Data::from).collect()
314 }
315 "instrument_status" => {
316 let statuses: Vec<InstrumentStatus> =
317 self.convert_record_batches_to_data(batches, false)?;
318 statuses.into_iter().map(Data::from).collect()
319 }
320 "instrument_closes" => {
321 let closes: Vec<InstrumentClose> =
322 self.convert_record_batches_to_data(batches, false)?;
323 closes.into_iter().map(Data::from).collect()
324 }
325 _ => {
326 if decode_data_cls.starts_with("custom/") {
327 decode_custom_batches_to_data(batches, false)?
328 } else {
329 continue;
331 }
332 }
333 };
334
335 all_data.extend(file_data);
336 }
337 }
338
339 all_data.sort_by_key(HasTsInit::ts_init);
341
342 Ok(all_data)
343 }
344
345 fn list_feather_files(
350 &self,
351 subdirectory: &str,
352 instance_id: &str,
353 data_name: &str,
354 identifiers: Option<&[String]>,
355 ) -> anyhow::Result<Vec<String>> {
356 let base_dir = make_object_store_path(&self.base_path, [subdirectory, instance_id]);
357
358 let mut files = Vec::new();
359
360 let list_result = self.execute_async(|| async {
361 let prefix = ObjectPath::from(format!("{base_dir}/"));
362 let mut stream = self.object_store.list(Some(&prefix));
363 let mut feather_files = Vec::new();
364
365 while let Some(object) = stream.next().await {
366 let object = object?;
367 let path_str = object.location.to_string();
368
369 if !path_str.ends_with(".feather") {
370 continue;
371 }
372
373 let Ok(path_data_name) =
374 type_name_from_session_feather_path(&path_str, subdirectory, instance_id)
375 else {
376 continue;
377 };
378
379 if path_data_name != data_name {
380 continue;
381 }
382
383 let path_identifier =
384 identifier_from_session_feather_path(&path_str, subdirectory, instance_id);
385
386 if let (Some(identifiers), Some(path_identifier)) =
387 (identifiers, path_identifier.as_deref())
388 && !Self::stream_identifier_matches(path_identifier, identifiers)
389 {
390 continue;
391 }
392
393 feather_files.push(path_str);
394 }
395
396 Ok::<Vec<String>, anyhow::Error>(feather_files)
397 })?;
398
399 files.extend(list_result);
400 files.sort();
401 Ok(files)
402 }
403
404 fn stream_identifier_matches(candidate: &str, identifiers: &[String]) -> bool {
405 identifiers.iter().any(|id| {
406 let safe_id = urisafe_instrument_id(id);
407 candidate.contains(id) || candidate.contains(&safe_id)
408 })
409 }
410
411 fn read_feather_file(&self, file_path: &str) -> anyhow::Result<Vec<RecordBatch>> {
413 let path = ObjectPath::from(file_path);
414 let batches = self.execute_async(|| async {
415 read_feather_record_batches(self.object_store.clone(), &path).await
416 })?;
417 Self::restore_staged_batches(batches)
418 }
419
420 fn restore_staged_batches(batches: Vec<RecordBatch>) -> anyhow::Result<Vec<RecordBatch>> {
421 let mut restored = Vec::new();
422 for batch in batches {
423 restored.extend(restore_staged_record_batches(batch)?);
424 }
425 Ok(restored)
426 }
427
428 fn convert_record_batches_to_data<T>(
430 &self,
431 batches: Vec<RecordBatch>,
432 use_ts_event_for_ts_init: bool,
433 ) -> anyhow::Result<Vec<T>>
434 where
435 T: DecodeDataFromRecordBatch + TryFrom<Data>,
436 {
437 if batches.is_empty() {
438 return Ok(Vec::new());
439 }
440
441 let mut all_data = Vec::new();
442
443 for batch in batches {
444 let batch = apply_stream_conversion_transform(
445 &batch,
446 StreamConversionOptions {
447 use_ts_event_for_ts_init,
448 convert_bar_type_to_external: false,
449 },
450 )?;
451 let metadata = batch.schema().metadata().clone();
452
453 let data_vec = T::decode_data_batch(&metadata, batch)
454 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
455
456 all_data.extend(data_vec);
457 }
458
459 Ok(to_variant::<T>(all_data))
460 }
461
462 pub(crate) fn convert_record_batches_to_typed<T>(
464 &self,
465 batches: Vec<RecordBatch>,
466 ) -> anyhow::Result<Vec<T>>
467 where
468 T: DecodeTypedFromRecordBatch,
469 {
470 if batches.is_empty() {
471 return Ok(Vec::new());
472 }
473
474 let mut all_data = Vec::new();
475
476 for batch in batches {
477 let metadata = batch.schema().metadata().clone();
478 let decoded = T::decode_typed_batch(&metadata, batch)
479 .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
480 all_data.extend(decoded);
481 }
482
483 Ok(all_data)
484 }
485
486 pub fn convert_stream_to_data(
549 &mut self,
550 instance_id: &str,
551 data_type: &CatalogDataType,
552 subdirectory: Option<&str>,
553 identifiers: Option<&[String]>,
554 use_ts_event_for_ts_init: bool,
555 ) -> anyhow::Result<()> {
556 let subdirectory = subdirectory.unwrap_or("backtest");
557
558 let stream_data_name: Cow<'static, str> = match data_type {
561 CatalogDataType::Data(data_type) => parquet_data_path_prefix(data_type),
562 CatalogDataType::Record(record_type) => record_path_prefix(record_type),
563 CatalogDataType::Instrument(class) => {
564 anyhow::bail!(
565 "Stream conversion stages instruments under the aggregate family, not {class}; \
566 pass the Instrument data type"
567 );
568 }
569 };
570
571 if !Self::is_supported_stream_data_type(&stream_data_name) {
572 anyhow::bail!("Stream conversion does not support {data_type}");
573 }
574
575 let feather_files =
577 self.list_feather_files(subdirectory, instance_id, &stream_data_name, identifiers)?;
578
579 if feather_files.is_empty() {
580 return Ok(());
581 }
582
583 for file_path in feather_files {
588 let batches = self.read_feather_file(&file_path)?;
589 self.convert_feather_batches_to_parquet(
590 subdirectory,
591 instance_id,
592 &stream_data_name,
593 &file_path,
594 &batches,
595 use_ts_event_for_ts_init,
596 None,
597 )?;
598 }
599
600 Ok(())
601 }
602
603 #[expect(
604 clippy::too_many_arguments,
605 reason = "the arguments describe one Feather source and its catalog destination"
606 )]
607 fn convert_feather_batches_to_parquet(
608 &self,
609 subdirectory: &str,
610 instance_id: &str,
611 catalog_data_name: &str,
612 feather_path: &str,
613 batches: &[RecordBatch],
614 use_ts_event_for_ts_init: bool,
615 replay_identity: Option<&str>,
616 ) -> anyhow::Result<()> {
617 let mut groups: IndexMap<Arc<Schema>, Vec<RecordBatch>> = IndexMap::new();
618
619 for batch in batches {
620 for restored in restore_staged_record_batches(batch.clone())? {
621 groups.entry(restored.schema()).or_default().push(restored);
622 }
623 }
624
625 for (index, group) in groups.into_values().enumerate() {
626 let Some(batch) =
627 Self::apply_stream_conversion_transforms(&group, use_ts_event_for_ts_init)
628 .map_err(|e| {
629 anyhow::anyhow!(
630 "Failed to apply stream conversion transforms for {feather_path}: {e}"
631 )
632 })?
633 else {
634 continue;
635 };
636
637 let (start_ts, end_ts) = Self::ts_init_range(&batch).map_err(|e| {
638 anyhow::anyhow!("Failed to determine ts_init range for {feather_path}: {e}")
639 })?;
640
641 let identifier = Self::identifier_from_batch_or_path(
642 &batch,
643 feather_path,
644 subdirectory,
645 instance_id,
646 );
647
648 let instrument_prefix = if catalog_data_name == "instruments" {
649 let class = batch
650 .schema()
651 .metadata()
652 .get("class")
653 .cloned()
654 .ok_or_else(|| anyhow::anyhow!("Staged instrument has no class metadata"))?;
655 Some(crate::catalog::types::instrument_path_prefix(
656 &class.parse()?,
657 ))
658 } else {
659 None
660 };
661
662 let catalog_data_name = instrument_prefix.unwrap_or(catalog_data_name);
663
664 let directory = if let Some(type_name) = catalog_data_name.strip_prefix("custom/") {
665 self.make_path_custom_data(type_name, identifier.as_deref())?
666 } else {
667 self.make_path(catalog_data_name, identifier.as_deref())?
668 };
669
670 let batch = Self::with_catalog_identifier_metadata(
671 batch,
672 catalog_data_name,
673 identifier.as_deref(),
674 )?;
675 let batches = vec![batch];
676 let group_identity = format!("{}/{index}", replay_identity.unwrap_or(feather_path));
677 self.write_parquet_file_checked(
678 &directory,
679 UnixNanos::from(start_ts),
680 UnixNanos::from(end_ts),
681 &batches,
682 false,
683 "File",
684 None,
685 Some(&group_identity),
686 )?;
687 }
688
689 Ok(())
690 }
691
692 fn with_catalog_identifier_metadata(
693 batch: RecordBatch,
694 catalog_data_name: &str,
695 identifier: Option<&str>,
696 ) -> anyhow::Result<RecordBatch> {
697 let Some(identifier) = identifier else {
698 return Ok(batch);
699 };
700 let metadata_key = if catalog_data_name == "bars" {
701 "bar_type"
702 } else {
703 "instrument_id"
704 };
705
706 if batch.schema().metadata().contains_key(metadata_key) {
707 return Ok(batch);
708 }
709
710 let mut metadata = batch.schema().metadata().clone();
711 metadata.insert(metadata_key.to_string(), identifier.to_string());
712 let schema = Arc::new(Schema::new_with_metadata(
713 batch.schema().fields().clone(),
714 metadata,
715 ));
716 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
717 }
718
719 fn apply_stream_conversion_transforms(
720 batches: &[RecordBatch],
721 use_ts_event_for_ts_init: bool,
722 ) -> anyhow::Result<Option<RecordBatch>> {
723 coalesce_stream_conversion_batches(
724 batches,
725 StreamConversionOptions {
726 use_ts_event_for_ts_init,
727 convert_bar_type_to_external: true,
728 },
729 )
730 }
731
732 fn ts_init_range(batch: &RecordBatch) -> anyhow::Result<(u64, u64)> {
733 let ts_init = Self::ts_init_array(batch)?;
734 if ts_init.is_empty() {
735 anyhow::bail!("Cannot convert empty stream batch to parquet");
736 }
737
738 if (0..ts_init.len()).any(|row| ts_init.is_null(row)) {
739 anyhow::bail!("ts_init column contains null values");
740 }
741
742 let start = ts_init
743 .value(0)
744 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
745 let end = ts_init
746 .value(ts_init.len() - 1)
747 .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
748 Ok((start, end))
749 }
750
751 fn ts_init_array(batch: &RecordBatch) -> anyhow::Result<U64ColumnRef<'_>> {
752 let ts_init_idx = batch
753 .schema()
754 .index_of("ts_init")
755 .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
756 U64ColumnRef::try_from_array(batch.column(ts_init_idx).as_ref())
757 .ok_or_else(|| anyhow::anyhow!("ts_init column has an unsupported type"))
758 }
759
760 fn identifier_from_batch_or_path(
761 batch: &RecordBatch,
762 feather_path: &str,
763 subdirectory: &str,
764 instance_id: &str,
765 ) -> Option<String> {
766 let metadata = batch.schema().metadata().clone();
767 if let Some(bar_type) = metadata.get("bar_type") {
768 return Some(bar_type.clone());
769 }
770
771 if let Some(instrument_id) = metadata.get("instrument_id") {
772 return Some(instrument_id.clone());
773 }
774
775 if let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch))
776 && identifiers.len() == 1
777 {
778 return identifiers.into_iter().next();
779 }
780
781 identifier_from_session_feather_path(feather_path, subdirectory, instance_id)
782 }
783
784 fn canonical_stream_data_name(data_name: &str) -> &str {
785 match data_name {
786 "quote_tick" => "quotes",
787 "trade_tick" => "trades",
788 "bar" => "bars",
789 "mark_price_update" => "mark_prices",
790 "index_price_update" => "index_prices",
791 "funding_rate_update" => "funding_rates",
792 "instrument_close" => "instrument_closes",
793 "order_book_delta" => "order_book_deltas",
794 other => other,
795 }
796 }
797
798 fn is_supported_stream_data_type(data_name: &str) -> bool {
799 data_name.starts_with("custom/")
800 || matches!(
801 data_name,
802 "instruments"
803 | "quotes"
804 | "trades"
805 | "order_book_deltas"
806 | "order_book_depths"
807 | "bars"
808 | "index_prices"
809 | "mark_prices"
810 | "option_greeks"
811 | "instrument_status"
812 | "instrument_closes"
813 | "funding_rates"
814 | "account_state"
815 | "order_initialized"
816 | "order_denied"
817 | "order_emulated"
818 | "order_submitted"
819 | "order_accepted"
820 | "order_rejected"
821 | "order_pending_cancel"
822 | "order_canceled"
823 | "order_cancel_rejected"
824 | "order_expired"
825 | "order_triggered"
826 | "order_pending_update"
827 | "order_released"
828 | "order_modify_rejected"
829 | "order_updated"
830 | "order_filled"
831 | "order_fill_voided"
832 | "position_opened"
833 | "position_changed"
834 | "position_closed"
835 | "position_adjusted"
836 | "order_snapshot"
837 | "position_snapshot"
838 | "order_status_report"
839 | "fill_report"
840 | "position_status_report"
841 | "execution_mass_status"
842 )
843 }
844}
845
846#[cfg(test)]
847mod canonical_name_tests {
848 use rstest::rstest;
849
850 use super::ParquetDataCatalog;
851
852 #[rstest]
853 #[case("quotes", "quotes")]
854 #[case("trades", "trades")]
855 #[case("bars", "bars")]
856 #[case("order_book_delta", "order_book_deltas")]
857 #[case("mark_prices", "mark_prices")]
858 #[case("index_prices", "index_prices")]
859 #[case("funding_rates", "funding_rates")]
860 #[case("instrument_closes", "instrument_closes")]
861 #[case("quotes", "quotes")]
862 fn canonical_stream_data_aliases(#[case] input: &str, #[case] expected: &str) {
863 assert_eq!(
864 ParquetDataCatalog::canonical_stream_data_name(input),
865 expected,
866 );
867 }
868}