1use std::{
13 fmt::Debug,
14 sync::{
15 Arc,
16 atomic::{AtomicBool, Ordering},
17 },
18};
19
20use nautilus_common::live::block_on_nautilus_with;
21use object_store::{
22 Error as ObjectStoreError, ObjectStoreExt, PutMode, PutOptions, path::Path as ObjectPath,
23};
24use serde::{Deserialize, Serialize};
25
26use super::catalog::ParquetDataCatalog;
27use crate::{
28 catalog::factory::CatalogConnectConfig,
29 common::{
30 conversion::FeatherConversionSummary, datafusion::identifiers_from_record_batches,
31 paths::create_local_directory, storage::create_storage_backend_from_path,
32 },
33 writer::{
34 factory::{PARQUET_WRITER_FACTORY_NAME, WriterConnectConfig, WriterFactoryRegistry},
35 feather::WriterClock,
36 materializer::read_feather_record_batches_with_identity,
37 promotion::{
38 PromotionBackend, PromotionResult, PromotionSession, PromotionWork,
39 StagedPromotionBackend,
40 },
41 run::{FeatherSessionSource, RunStatus},
42 staged::{StagedFeatherWriter, StagedWriter},
43 traits::StreamingDataSink,
44 },
45};
46
47pub(crate) fn register_factory(registry: &mut WriterFactoryRegistry) {
48 registry.insert(
49 PARQUET_WRITER_FACTORY_NAME.to_string(),
50 Arc::new(parquet_writer_factory),
51 );
52}
53
54fn parquet_writer_factory(
55 config: &WriterConnectConfig,
56 clock: WriterClock,
57) -> anyhow::Result<StreamingDataSink> {
58 Ok(Box::new(ParquetWriter::new(config, clock)?))
59}
60
61#[expect(
62 clippy::struct_excessive_bools,
63 reason = "the booleans are independent writer policy and lifecycle flags"
64)]
65struct ParquetWriter {
66 core: StagedFeatherWriter<ParquetPromotionBackend>,
67 session: PromotionSession,
68 source: FeatherSessionSource,
69 catalog: CatalogConnectConfig,
70 interval_ms: Option<u64>,
71 promote_on_close: bool,
72 delete_feather_after_commit: bool,
73 use_ts_event_for_ts_init: bool,
74 legacy_manifest_missing: Arc<AtomicBool>,
75 has_data: bool,
76 run_status: RunStatus,
77}
78
79impl Debug for ParquetWriter {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct(stringify!(ParquetWriter))
82 .field("staging_uri", &self.core.storage.original_uri)
83 .field("catalog_uri", &self.catalog.uri)
84 .field("interval_ms", &self.interval_ms)
85 .finish_non_exhaustive()
86 }
87}
88
89impl ParquetWriter {
90 fn new(config: &WriterConnectConfig, clock: WriterClock) -> anyhow::Result<Self> {
91 let catalog = config
92 .required_catalog(PARQUET_WRITER_FACTORY_NAME)?
93 .clone();
94 let storage = create_storage_backend_from_path(&config.uri, None)?;
95 let session = StagedFeatherWriter::<ParquetPromotionBackend>::required_session(
96 &storage.original_uri,
97 "Parquet writer URI",
98 )?;
99 let source_storage = create_storage_backend_from_path(&session.root_uri, None)?;
100
101 let source = FeatherSessionSource::new(
102 source_storage,
103 session.environment,
104 session.instance_id.clone(),
105 );
106
107 create_local_directory(&catalog.uri)?;
110
111 let legacy_manifest_missing = Arc::new(AtomicBool::new(false));
112
113 ParquetPromotionBackend::new(&catalog, Arc::clone(&legacy_manifest_missing))?;
114
115 let mut core = StagedFeatherWriter::new(
116 storage.clone(),
117 &source,
118 clock,
119 config.rotation_config.clone(),
120 None,
121 config.flush_interval_ms,
122 config.record_filter.clone(),
123 )?;
124
125 block_on_nautilus_with(|| {
126 storage.write_current_run_manifest(
127 session.environment,
128 &session.instance_id,
129 RunStatus::InProgress,
130 true,
131 )
132 })?;
133
134 let timer_catalog = catalog.clone();
135 core.warn_if_orphan_feather_present("Parquet");
136 let timer_legacy_manifest_missing = Arc::clone(&legacy_manifest_missing);
137 core.start_promotion_timer(
138 config.promotion_interval_ms,
139 source.clone(),
140 "parquet-promotion",
141 move || {
142 ParquetPromotionBackend::new(
143 &timer_catalog,
144 Arc::clone(&timer_legacy_manifest_missing),
145 )
146 },
147 config.use_ts_event_for_ts_init,
148 config.delete_feather_after_promotion,
149 )?;
150
151 Ok(Self {
152 core,
153 catalog,
154 session,
155 source,
156 interval_ms: config.promotion_interval_ms,
157 promote_on_close: config.promote_on_close,
158 delete_feather_after_commit: config.delete_feather_after_promotion,
159 use_ts_event_for_ts_init: config.use_ts_event_for_ts_init,
160 legacy_manifest_missing,
161 has_data: false,
162 run_status: RunStatus::InProgress,
163 })
164 }
165
166 fn record_status(&mut self, status: RunStatus) -> anyhow::Result<()> {
167 if self.run_status == status {
168 return Ok(());
169 }
170
171 block_on_nautilus_with(|| {
172 self.core.storage.write_current_run_manifest(
173 self.session.environment,
174 &self.session.instance_id,
175 status,
176 !self.has_data,
177 )
178 })?;
179
180 self.run_status = status;
181 Ok(())
182 }
183
184 fn mark_non_empty(&mut self) -> anyhow::Result<()> {
185 if self.has_data {
186 return Ok(());
187 }
188
189 block_on_nautilus_with(|| {
190 self.core.storage.write_current_run_manifest(
191 self.session.environment,
192 &self.session.instance_id,
193 RunStatus::InProgress,
194 false,
195 )
196 })?;
197
198 self.has_data = true;
199 Ok(())
200 }
201
202 fn interval_ns(&self) -> Option<u64> {
203 self.interval_ms
204 .filter(|interval_ms| *interval_ms > 0)
205 .map(|interval_ms| interval_ms.saturating_mul(1_000_000))
206 }
207
208 fn prepare_promotion(&self) -> anyhow::Result<Option<PromotionWork<ParquetPromotionBackend>>> {
209 let backend =
210 ParquetPromotionBackend::new(&self.catalog, Arc::clone(&self.legacy_manifest_missing))?;
211 self.core.prepare_promotion(
212 backend,
213 self.source.clone(),
214 self.use_ts_event_for_ts_init,
215 self.delete_feather_after_commit,
216 )
217 }
218
219 fn finalize(
220 &mut self,
221 result: PromotionResult,
222 ) -> anyhow::Result<Vec<FeatherConversionSummary>> {
223 if result.converted.is_err() {
224 if result.run_state_recorded {
225 self.run_status = RunStatus::Failed;
226 } else if let Err(e) = self.record_status(RunStatus::Failed) {
227 log::warn!("Failed to record Parquet run Failed state: {e}");
228 }
229 } else if result.run_state_recorded {
230 self.run_status = RunStatus::Promoted;
231 }
232
233 self.core.finalize_promotion(result)
234 }
235
236 fn drain_completed(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
237 let mut converted = Vec::new();
238 for result in self.core.promotion_driver.drain_completed()? {
239 converted.extend(self.finalize(result)?);
240 }
241
242 Ok(converted)
243 }
244
245 fn wait(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
246 let mut converted = Vec::new();
247 for result in self.core.promotion_driver.wait()? {
248 converted.extend(self.finalize(result)?);
249 }
250
251 Ok(converted)
252 }
253
254 fn promote_now(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
255 let mut converted = self.wait()?;
256 if let Some(work) = self.prepare_promotion()? {
257 converted.extend(self.finalize(work.execute())?);
258 }
259
260 Ok(converted)
261 }
262}
263
264impl StagedWriter for ParquetWriter {
265 type Backend = ParquetPromotionBackend;
266
267 fn staged(&mut self) -> &mut StagedFeatherWriter<Self::Backend> {
268 &mut self.core
269 }
270
271 fn mark_non_empty(&mut self) -> anyhow::Result<()> {
272 Self::mark_non_empty(self)
273 }
274
275 fn maybe_promote(&mut self) -> anyhow::Result<()> {
276 self.drain_completed()?;
277
278 let Some(interval_ns) = self.interval_ns() else {
279 return Ok(());
280 };
281
282 if self
283 .core
284 .promotion_driver
285 .is_due(self.core.clock.timestamp_ns(), interval_ns)
286 && let Some(work) = self.prepare_promotion()?
287 {
288 self.core
289 .promotion_driver
290 .submit(work, "parquet-promotion")?;
291 self.core
292 .promotion_driver
293 .mark_committed_at(self.core.clock.timestamp_ns());
294 }
295
296 Ok(())
297 }
298
299 fn wait_for_promotions(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
300 self.wait()
301 }
302
303 fn promote_on_flush(&self) -> bool {
304 self.interval_ns().is_some_and(|interval_ns| {
305 self.core
306 .promotion_driver
307 .is_due(self.core.clock.timestamp_ns(), interval_ns)
308 })
309 }
310
311 fn promote_on_close(&self) -> bool {
312 self.promote_on_close
313 }
314
315 fn promote_now(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
316 Self::promote_now(self)
317 }
318
319 fn record_completed(&mut self) {
320 if self.run_status == RunStatus::InProgress
321 && let Err(e) = self.record_status(RunStatus::Completed)
322 {
323 log::warn!("Failed to complete Parquet run manifest: {e}");
324 }
325 }
326}
327
328impl Drop for ParquetWriter {
329 fn drop(&mut self) {
330 self.core.stop_promotion_timer();
331
332 let promotion_failed = if let Err(e) = self.wait() {
333 log::warn!("ParquetWriter dropped with pending promotion error: {e}");
334 true
335 } else {
336 false
337 };
338
339 if self.run_status == RunStatus::InProgress {
340 let status = if promotion_failed || !self.core.is_closed().unwrap_or(false) {
341 RunStatus::Failed
342 } else {
343 RunStatus::Completed
344 };
345
346 if let Err(e) = self.record_status(status) {
347 log::warn!(
348 "Failed to record Parquet run {} state during drop: {e}",
349 status.as_str()
350 );
351 }
352 }
353 }
354}
355
356const PROMOTION_MANIFEST: &str = "_nautilus_promotions.json";
357const PROMOTION_MARKERS: &str = "_nautilus_promotions";
358
359struct ParquetPromotionBackend {
360 catalog: ParquetDataCatalog,
361 legacy_manifest_missing: Arc<AtomicBool>,
362}
363
364impl ParquetPromotionBackend {
365 fn new(
366 catalog: &CatalogConnectConfig,
367 legacy_manifest_missing: Arc<AtomicBool>,
368 ) -> anyhow::Result<Self> {
369 Ok(Self {
370 catalog: super::open_catalog(catalog)?,
371 legacy_manifest_missing,
372 })
373 }
374
375 fn manifest_path(&self) -> ObjectPath {
376 self.catalog_path(PROMOTION_MANIFEST)
377 }
378
379 fn manifest(&self) -> anyhow::Result<ParquetPromotionManifest> {
380 if self.legacy_manifest_missing.load(Ordering::Relaxed) {
381 return Ok(ParquetPromotionManifest::default());
382 }
383
384 let path = self.manifest_path();
385 block_on_nautilus_with(|| async {
386 let result = match self.catalog.object_store.get(&path).await {
387 Ok(result) => result,
388 Err(ObjectStoreError::NotFound { .. }) => {
389 self.legacy_manifest_missing.store(true, Ordering::Relaxed);
390 return Ok(ParquetPromotionManifest::default());
391 }
392 Err(e) => return Err(e.into()),
393 };
394
395 Ok(serde_json::from_slice(&result.bytes().await?)?)
396 })
397 }
398
399 fn marker_path(&self, identity: &str) -> ObjectPath {
400 let digest = blake3::hash(identity.as_bytes()).to_hex();
401 self.catalog_path(&format!("{PROMOTION_MARKERS}/{digest}.json"))
402 }
403
404 fn catalog_path(&self, path: &str) -> ObjectPath {
405 let base = self.catalog.base_path.trim_matches('/');
406 if base.is_empty() {
407 ObjectPath::from(path)
408 } else {
409 ObjectPath::from(format!("{base}/{path}"))
410 }
411 }
412
413 fn identity_recorded(&self, identity: &str) -> anyhow::Result<bool> {
414 let path = self.marker_path(identity);
415
416 let marker_exists = block_on_nautilus_with(|| async {
417 match self.catalog.object_store.head(&path).await {
418 Ok(_) => Ok::<bool, anyhow::Error>(true),
419 Err(ObjectStoreError::NotFound { .. }) => Ok(false),
420 Err(e) => Err(anyhow::Error::from(e)),
421 }
422 })?;
423
424 Ok(marker_exists
425 || self
426 .manifest()?
427 .identities
428 .iter()
429 .any(|item| item == identity))
430 }
431
432 fn record_identity(&self, identity: &str) -> anyhow::Result<()> {
433 let bytes = serde_json::to_vec(identity)?;
434 let path = self.marker_path(identity);
435 block_on_nautilus_with(|| async {
436 match self
437 .catalog
438 .object_store
439 .put_opts(
440 &path,
441 bytes.into(),
442 PutOptions {
443 mode: PutMode::Create,
444 ..Default::default()
445 },
446 )
447 .await
448 {
449 Ok(_) | Err(ObjectStoreError::AlreadyExists { .. }) => Ok(()),
450 Err(e) => Err(e.into()),
451 }
452 })
453 }
454}
455
456impl PromotionBackend for ParquetPromotionBackend {
457 type Source = FeatherSessionSource;
458
459 const NAME: &'static str = "Parquet";
460
461 fn convert_file(
462 &mut self,
463 source: &Self::Source,
464 file: &str,
465 use_ts_event_for_ts_init: bool,
466 record_promoted: bool,
467 ) -> anyhow::Result<Option<FeatherConversionSummary>> {
468 let object_path = ObjectPath::parse(file)?;
469
470 let read = block_on_nautilus_with(|| {
471 read_feather_record_batches_with_identity(
472 source.storage.object_store.clone(),
473 &object_path,
474 )
475 })?;
476
477 let identifiers = identifiers_from_record_batches(&read.batches)
478 .ok()
479 .filter(|identifiers| !identifiers.is_empty());
480 let identity = feather_replay_identity(
481 &source.storage.original_uri,
482 file,
483 &read.content_hash,
484 identifiers.as_deref(),
485 );
486
487 if self.identity_recorded(&identity)? {
488 return Ok(None);
489 }
490
491 let summary = self.catalog.promote_feather_file(
492 source,
493 file,
494 read.batches,
495 use_ts_event_for_ts_init,
496 &identity,
497 )?;
498
499 if summary.is_some() && record_promoted {
500 self.record_identity(&identity)?;
501 }
502
503 Ok(summary)
504 }
505
506 fn delete_file(&mut self, source: &Self::Source, file: &str) -> anyhow::Result<()> {
507 let object_path = ObjectPath::parse(file)?;
508 block_on_nautilus_with(|| async {
509 source.storage.object_store.delete(&object_path).await?;
510 Ok::<(), anyhow::Error>(())
511 })
512 }
513}
514
515impl StagedPromotionBackend for ParquetPromotionBackend {
516 const REQUIRES_SESSION: bool = true;
517
518 fn record_run_state(
519 &mut self,
520 source: &FeatherSessionSource,
521 _staging_uri: &str,
522 status: RunStatus,
523 empty: bool,
524 _error: Option<&str>,
525 ) -> anyhow::Result<()> {
526 block_on_nautilus_with(|| {
527 source.storage.write_run_manifest(
528 source.environment,
529 &source.instance_id,
530 status,
531 empty,
532 )
533 })
534 }
535}
536
537#[derive(Debug, Default, Deserialize, Serialize)]
538struct ParquetPromotionManifest {
539 identities: Vec<String>,
540}
541
542fn feather_replay_identity(
543 source_uri: &str,
544 source_path: &str,
545 content_hash: &str,
546 identifiers: Option<&[String]>,
547) -> String {
548 let mut identifiers = identifiers.map(<[String]>::to_vec);
549 if let Some(identifiers) = identifiers.as_mut() {
550 identifiers.sort();
551 identifiers.dedup();
552 }
553
554 let identity = serde_json::json!({
555 "source_uri": source_uri,
556 "source_path": source_path,
557 "content_hash": content_hash,
558 "identifiers": identifiers,
559 });
560 format!(
561 "nautilus-feather:{}",
562 blake3::hash(identity.to_string().as_bytes()).to_hex(),
563 )
564}
565
566#[cfg(test)]
567mod tests {
568 use std::{
569 fs,
570 path::{Path, PathBuf},
571 sync::atomic::AtomicU64,
572 };
573
574 use arrow::datatypes::SchemaRef;
575 use nautilus_common::enums::Environment;
576 use nautilus_core::{Params, UnixNanos};
577 use nautilus_model::{
578 data::{
579 BookOrder, Data, DataBatch, NautilusDataType, NautilusRecordType, OrderBookDelta,
580 QuoteTick,
581 },
582 enums::{BookAction, OrderSide, RecordFlag},
583 identifiers::InstrumentId,
584 types::{ERROR_PRICE, Price, Quantity},
585 };
586 use nautilus_serialization::arrow::{DecodeTypedFromRecordBatch, EncodeToRecordBatch};
587 use object_store::memory::InMemory;
588 use parquet::{
589 basic::Compression,
590 file::{
591 metadata::RowGroupMetaData,
592 reader::{FileReader, SerializedFileReader},
593 },
594 };
595 use rstest::rstest;
596 use tempfile::TempDir;
597
598 use super::*;
599 use crate::{
600 backend::parquet::io::read_parquet_from_object_store,
601 catalog::traits::{CatalogQuery, CatalogReader},
602 common::{paths::normalize_path_to_uri, storage::RUN_MANIFEST_FILENAME},
603 config::{CatalogCompression, DataCatalogConfig},
604 test_data::RustTestHashMapCustomData,
605 writer::{
606 feather::{FEATHER_PARTIAL_EXTENSION, FeatherWriter, RotationConfig},
607 promotion::{list_session_feather_files, tests::assert_scheduled_count},
608 },
609 };
610
611 #[rstest]
612 fn parquet_default_close_promotes_and_honors_source_retention(
613 #[values(false, true)] delete_source: bool,
614 ) {
615 let directory = TempDir::new().unwrap();
616 let staging = directory.path().join("backtest").join("run-1");
617 let mut config =
618 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
619 config.delete_feather_after_promotion = delete_source;
620 let mut sink =
621 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
622 .unwrap();
623 let quote = sample_quote();
624 let mut second = quote;
625 second.instrument_id = InstrumentId::from("BTC/USD.SIM");
626 second.bid_price = Price::from("100.1234");
627 second.ask_price = Price::from("100.5678");
628 second.ts_init = UnixNanos::from(24);
629 let mut catalog = ParquetDataCatalog::from_uri(
630 directory.path().to_str().unwrap(),
631 None,
632 None,
633 None,
634 None,
635 )
636 .unwrap();
637 sink.write_data(Data::Quote(quote)).unwrap();
638 sink.write_data(Data::Quote(second)).unwrap();
639 sink.flush().unwrap();
640 let before = catalog
641 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
642 .unwrap();
643 sink.close().unwrap();
644 let after = catalog
645 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
646 .unwrap();
647 let storage = create_storage_backend_from_path(staging.to_str().unwrap(), None).unwrap();
648 let staged = block_on_nautilus_with(|| storage.list_files("", None))
649 .unwrap()
650 .into_iter()
651 .filter(|path| path.ends_with(".feather"))
652 .count();
653 assert!(before.is_empty());
654
655 let DataBatch::Quote(rows) = after else {
656 panic!("expected quotes")
657 };
658
659 assert_eq!(rows.as_ref(), &[quote, second]);
660 assert_eq!(staged, usize::from(!delete_source));
661 }
662
663 #[rstest]
664 fn parquet_writer_recovers_partial_files_at_startup() {
665 let directory = TempDir::new().unwrap();
666 let staging = directory.path().join("backtest").join("run-recover");
667 let quote = sample_quote();
668
669 let mut crashed = FeatherWriter::new(
671 staging.clone(),
672 WriterClock::Test(Arc::new(AtomicU64::new(0))),
673 RotationConfig::NoRotation,
674 None,
675 None,
676 );
677 crashed.write(quote).unwrap();
678 crashed.close().unwrap();
679 let sealed = staging.join("quotes").join("quotes_0.feather");
680 std::fs::rename(&sealed, sealed.with_extension(FEATHER_PARTIAL_EXTENSION)).unwrap();
681
682 let config =
683 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
684 let mut sink =
685 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
686 .unwrap();
687 sink.close().unwrap();
688
689 let mut catalog = ParquetDataCatalog::from_uri(
690 directory.path().to_str().unwrap(),
691 None,
692 None,
693 None,
694 None,
695 )
696 .unwrap();
697 let rows = catalog
698 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
699 .unwrap();
700
701 let DataBatch::Quote(rows) = rows else {
702 panic!("expected quotes")
703 };
704
705 assert_eq!(rows.as_ref(), &[quote]);
706 }
707
708 #[rstest]
709 fn parquet_close_promotes_per_identifier_file_with_non_ascii_identifier(
710 #[values(false, true)] delete_source: bool,
711 ) {
712 let directory = TempDir::new().unwrap();
713 let staging = directory.path().join("backtest").join("run-1");
714 let (quote, staged_file) = stage_per_identifier_quote(&staging);
715 let mut config =
716 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
717 config.delete_feather_after_promotion = delete_source;
718 let mut sink =
719 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
720 .unwrap();
721
722 sink.close().unwrap();
723
724 let mut catalog = ParquetDataCatalog::from_uri(
725 directory.path().to_str().unwrap(),
726 None,
727 None,
728 None,
729 None,
730 )
731 .unwrap();
732
733 let DataBatch::Quote(rows) = catalog
734 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
735 .unwrap()
736 else {
737 panic!("expected quotes")
738 };
739
740 assert_eq!(rows.as_ref(), &[quote]);
741 assert_eq!(staged_file.exists(), !delete_source);
742 }
743
744 #[rstest]
745 fn parquet_manual_conversion_reads_per_identifier_file_with_non_ascii_identifier() {
746 let directory = TempDir::new().unwrap();
747 let staging = directory.path().join("backtest").join("run-1");
748 let (quote, _) = stage_per_identifier_quote(&staging);
749 let mut catalog = ParquetDataCatalog::from_uri(
750 directory.path().to_str().unwrap(),
751 None,
752 None,
753 None,
754 None,
755 )
756 .unwrap();
757
758 catalog
759 .convert_stream_to_data(
760 "run-1",
761 &NautilusDataType::QuoteTick.into(),
762 Environment::Backtest,
763 None,
764 false,
765 )
766 .unwrap();
767
768 let DataBatch::Quote(rows) = catalog
769 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
770 .unwrap()
771 else {
772 panic!("expected quotes")
773 };
774
775 assert_eq!(rows.as_ref(), &[quote]);
776 }
777
778 #[rstest]
779 fn parquet_promotion_writes_non_ascii_identifier_to_object_store() {
780 let directory = TempDir::new().unwrap();
781 let staging = directory.path().join("backtest").join("run-1");
782 let (quote, staged_file) = stage_per_identifier_quote(&staging);
783
784 let source = FeatherSessionSource::new(
785 create_storage_backend_from_path(directory.path().to_str().unwrap(), None).unwrap(),
786 Environment::Backtest,
787 "run-1",
788 );
789 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
790 catalog.base_path = "catalog".to_string();
791 catalog.original_uri = "s3://test-bucket/catalog".to_string();
792 catalog.object_store = Arc::new(InMemory::new());
793
794 let mut backend = ParquetPromotionBackend {
795 catalog,
796 legacy_manifest_missing: Arc::default(),
797 };
798
799 let files =
800 list_session_feather_files(&source.storage, Environment::Backtest, "run-1").unwrap();
801
802 for file in &files {
803 backend.convert_file(&source, file, false, true).unwrap();
804 backend.delete_file(&source, file).unwrap();
805 }
806
807 let DataBatch::Quote(rows) = backend
808 .catalog
809 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
810 .unwrap()
811 else {
812 panic!("expected quotes")
813 };
814
815 assert_eq!(files.len(), 1);
816 assert_eq!(rows.as_ref(), &[quote]);
817 assert!(!staged_file.exists());
818 }
819
820 #[rstest]
821 #[case("backtest")]
822 #[case("sandbox")]
823 #[case("live")]
824 fn parquet_close_promotes_into_missing_local_catalog(#[case] environment: &str) {
825 let directory = TempDir::new().unwrap();
826 let staging = directory
827 .path()
828 .join("stream")
829 .join(environment)
830 .join("run-1");
831 let catalog_path = directory.path().join("charts").join("catalog");
832
833 let config = WriterConnectConfig::new(
834 staging.to_string_lossy(),
835 Some(CatalogConnectConfig::new(
836 catalog_path.to_string_lossy(),
837 None,
838 )),
839 );
840 let mut sink =
841 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
842 .unwrap();
843 let quote = sample_quote();
844
845 sink.write_data(Data::Quote(quote)).unwrap();
846 sink.close().unwrap();
847
848 let mut catalog =
849 ParquetDataCatalog::from_uri(catalog_path.to_str().unwrap(), None, None, None, None)
850 .unwrap();
851
852 let DataBatch::Quote(rows) = catalog
853 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
854 .unwrap()
855 else {
856 panic!("expected quotes")
857 };
858
859 let manifest: serde_json::Value =
860 serde_json::from_slice(&std::fs::read(staging.join(RUN_MANIFEST_FILENAME)).unwrap())
861 .unwrap();
862
863 assert_eq!(rows.as_ref(), &[quote]);
864 assert_eq!(manifest["kind"], environment);
865 assert_eq!(manifest["status"], "promoted");
866 assert!(!catalog_path.join(environment).exists());
867 }
868
869 #[rstest]
870 #[case::non_ascii("run-é")]
871 #[case::space("run 1")]
872 #[case::fragment("run#1")]
873 fn parquet_writer_rejects_run_id_that_paths_encode(#[case] run_id: &str) {
874 let directory = TempDir::new().unwrap();
875 let staging = directory.path().join("backtest").join(run_id);
876 let config =
877 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
878
879 let error = parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
880 .unwrap_err();
881
882 let staging_uri = normalize_path_to_uri(&staging.to_string_lossy()).unwrap();
883 assert_eq!(
884 error.to_string(),
885 format!(
886 "Parquet writer URI '{staging_uri}' has a run ID that object-store paths \
887 percent-encode; use a run ID without spaces, non-ASCII, or reserved characters",
888 )
889 );
890 }
891
892 #[rstest]
893 #[case::windows_separators(r"file:///C:\catalog\backtest\run-1", "file:///C:/catalog")]
894 #[case::windows_trailing_separator(r"file:///C:\catalog\backtest\run-1\", "file:///C:/catalog")]
895 #[case::trailing_separator("file:///tmp/catalog/backtest/run-1/", "file:///tmp/catalog")]
896 #[case::parent_components(
897 "file:///tmp/unused/../catalog/backtest/run-1",
898 "file:///tmp/catalog"
899 )]
900 fn parquet_writer_session_accepts_separator_forms(#[case] uri: &str, #[case] root_uri: &str) {
901 let session = StagedFeatherWriter::<ParquetPromotionBackend>::required_session(
902 uri,
903 "Parquet writer URI",
904 )
905 .unwrap();
906
907 assert_eq!(
908 session,
909 PromotionSession {
910 root_uri: root_uri.to_string(),
911 environment: Environment::Backtest,
912 instance_id: "run-1".to_string(),
913 }
914 );
915 }
916
917 #[rstest]
918 fn parquet_writer_requires_a_catalog() {
919 let directory = TempDir::new().unwrap();
920 let staging = directory.path().join("backtest").join("run-1");
921 let config = WriterConnectConfig::new(staging.to_string_lossy(), None);
922
923 let error = parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
924 .unwrap_err();
925
926 assert_eq!(
927 error.to_string(),
928 "Parquet writer requires a promotion catalog"
929 );
930 assert!(!staging.exists());
931 }
932
933 #[cfg(feature = "cloud")]
936 #[rstest]
937 fn parquet_writer_stages_locally_for_a_remote_catalog() {
938 let directory = TempDir::new().unwrap();
939 let staging = directory.path().join("backtest").join("run-1");
940
941 let config = WriterConnectConfig::new(
942 staging.to_string_lossy(),
943 Some(CatalogConnectConfig::new("s3://bucket/catalog", None)),
944 );
945
946 let mut sink =
947 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
948 .unwrap();
949 sink.close().unwrap();
950 drop(sink);
951
952 let manifest: serde_json::Value =
953 serde_json::from_slice(&std::fs::read(staging.join(RUN_MANIFEST_FILENAME)).unwrap())
954 .unwrap();
955 assert_eq!(manifest["kind"], "backtest");
956 assert_eq!(manifest["instance_id"], "run-1");
957 assert_eq!(manifest["status"], "completed");
958 assert_eq!(manifest["empty"], true);
959 }
960
961 #[rstest]
962 fn parquet_promotion_writes_the_direct_write_schema() {
963 let directory = TempDir::new().unwrap();
964
965 let config = WriterConnectConfig::new(
966 directory
967 .path()
968 .join("backtest/run-schema")
969 .to_string_lossy(),
970 Some(local_catalog(&directory)),
971 );
972 let mut sink =
973 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
974 .unwrap();
975 let quote = sample_quote();
976 sink.write_data(Data::Quote(quote)).unwrap();
977 sink.close().unwrap();
978
979 let promoted = ParquetDataCatalog::from_uri(
980 directory.path().to_str().unwrap(),
981 None,
982 None,
983 None,
984 None,
985 )
986 .unwrap();
987 let direct_directory = TempDir::new().unwrap();
988 let direct = ParquetDataCatalog::from_uri(
989 direct_directory.path().to_str().unwrap(),
990 None,
991 None,
992 None,
993 None,
994 )
995 .unwrap();
996 direct.write_to_parquet(&[quote], None, None, None).unwrap();
997
998 assert_eq!(
999 quote_file_schema(&promoted).fields(),
1000 quote_file_schema(&direct).fields(),
1001 );
1002 }
1003
1004 #[rstest]
1005 fn parquet_promotion_honors_catalog_settings() {
1006 let directory = TempDir::new().unwrap();
1007 let staging = directory.path().join("backtest").join("run-1");
1008 let catalog = DataCatalogConfig::builder()
1009 .path(directory.path().to_string_lossy().to_string())
1010 .batch_size(7)
1011 .compression(CatalogCompression::Uncompressed)
1012 .max_row_group_size(1)
1013 .build()
1014 .unwrap()
1015 .connect_config();
1016 let backend =
1017 ParquetPromotionBackend::new(&catalog, Arc::new(AtomicBool::new(false))).unwrap();
1018
1019 let config = WriterConnectConfig::new(staging.to_string_lossy(), Some(catalog));
1020 let mut sink =
1021 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1022 .unwrap();
1023 let quote = sample_quote();
1024 sink.write_data(Data::Quote(quote)).unwrap();
1025 sink.write_data(Data::Quote(QuoteTick {
1026 ts_event: UnixNanos::from(29),
1027 ts_init: UnixNanos::from(31),
1028 ..quote
1029 }))
1030 .unwrap();
1031
1032 sink.close().unwrap();
1033
1034 let files = ParquetDataCatalog::new(directory.path(), None, None, None, None)
1035 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1036 .unwrap();
1037 let reader = SerializedFileReader::new(
1038 std::fs::File::open(directory.path().join(&files[0])).unwrap(),
1039 )
1040 .unwrap();
1041 let metadata = reader.metadata();
1042
1043 assert_eq!(backend.catalog.batch_size, 7);
1044 assert_eq!(files.len(), 1);
1045 assert_eq!(metadata.num_row_groups(), 2);
1046 assert!(
1047 metadata
1048 .row_groups()
1049 .iter()
1050 .flat_map(RowGroupMetaData::columns)
1051 .all(|column| column.compression() == Compression::UNCOMPRESSED)
1052 );
1053 }
1054
1055 #[rstest]
1056 fn parquet_writer_rejects_unknown_catalog_param() {
1057 let directory = TempDir::new().unwrap();
1058 let staging = directory.path().join("backtest").join("run-1");
1059 let mut catalog = local_catalog(&directory);
1060 let mut params = Params::new();
1061 params.insert("compression".to_string(), serde_json::json!(0));
1062 catalog.params = Some(params);
1063
1064 let config = WriterConnectConfig::new(staging.to_string_lossy(), Some(catalog));
1065 let error = parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1066 .unwrap_err();
1067
1068 assert_eq!(
1069 error.to_string(),
1070 "Unknown Parquet catalog param 'compression': this catalog takes no params"
1071 );
1072 }
1073
1074 fn quote_file_schema(catalog: &ParquetDataCatalog) -> SchemaRef {
1075 let files = catalog
1076 .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1077 .unwrap();
1078 assert_eq!(files.len(), 1);
1079
1080 let path = ObjectPath::from(files[0].as_str());
1081
1082 let (_, schema) = block_on_nautilus_with(|| {
1083 read_parquet_from_object_store(catalog.object_store.clone(), &path)
1084 })
1085 .unwrap();
1086
1087 schema
1088 }
1089
1090 #[rstest]
1091 #[case(None)]
1092 #[case(Some(0))]
1093 #[case(Some(1))]
1094 fn parquet_interval_uses_test_clock_and_close_can_leave_staged_data(
1095 #[case] interval: Option<u64>,
1096 ) {
1097 let directory = TempDir::new().unwrap();
1098 let staging = directory.path().join("backtest").join("run-2");
1099 let mut config =
1100 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1101 config.promotion_interval_ms = interval;
1102 config.promote_on_close = false;
1103 let clock = Arc::new(AtomicU64::new(0));
1104 let mut sink =
1105 parquet_writer_factory(&config, WriterClock::Test(Arc::clone(&clock))).unwrap();
1106 let quote = sample_quote();
1107 sink.write_data(Data::Quote(quote)).unwrap();
1108 clock.store(1_000_000, Ordering::Relaxed);
1109 sink.flush().unwrap();
1110 sink.close().unwrap();
1111 let mut catalog = ParquetDataCatalog::from_uri(
1112 directory.path().to_str().unwrap(),
1113 None,
1114 None,
1115 None,
1116 None,
1117 )
1118 .unwrap();
1119 let before_manual = catalog
1120 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
1121 .unwrap();
1122
1123 if interval != Some(1) {
1124 catalog
1125 .convert_stream_to_data(
1126 "run-2",
1127 &NautilusDataType::QuoteTick.into(),
1128 Environment::Backtest,
1129 None,
1130 false,
1131 )
1132 .unwrap();
1133 }
1134
1135 let after = catalog
1136 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
1137 .unwrap();
1138 assert_eq!(before_manual.len(), usize::from(interval == Some(1)));
1139
1140 let DataBatch::Quote(rows) = after else {
1141 panic!("expected quotes")
1142 };
1143
1144 assert_eq!(rows.as_ref(), &[quote]);
1145 }
1146
1147 #[rstest]
1148 fn parquet_streaming_funding_round_trip() {
1149 use nautilus_model::data::FundingRateUpdate;
1150 let directory = TempDir::new().unwrap();
1151
1152 let config = WriterConnectConfig::new(
1153 directory
1154 .path()
1155 .join("backtest/run-funding")
1156 .to_string_lossy(),
1157 Some(local_catalog(&directory)),
1158 );
1159 let mut sink =
1160 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1161 .unwrap();
1162
1163 let funding = FundingRateUpdate::new(
1164 InstrumentId::from("AUD/USD.SIM"),
1165 "0.0012".parse().unwrap(),
1166 Some(480),
1167 Some(789.into()),
1168 123.into(),
1169 456.into(),
1170 );
1171 assert!(sink.write_any(&funding).unwrap());
1172 sink.close().unwrap();
1173 let mut catalog = ParquetDataCatalog::from_uri(
1174 directory.path().to_str().unwrap(),
1175 None,
1176 None,
1177 None,
1178 None,
1179 )
1180 .unwrap();
1181 let rows = catalog
1182 .query_batch(&CatalogQuery::new(NautilusDataType::FundingRateUpdate))
1183 .unwrap();
1184
1185 let DataBatch::FundingRate(rows) = rows else {
1186 panic!("expected funding rates")
1187 };
1188
1189 assert_eq!(rows.as_ref(), &[funding]);
1190 }
1191
1192 #[rstest]
1193 fn parquet_streaming_write_error_reaches_flush() {
1194 let directory = TempDir::new().unwrap();
1195
1196 let config = WriterConnectConfig::new(
1197 directory
1198 .path()
1199 .join("backtest/run-error")
1200 .to_string_lossy(),
1201 Some(local_catalog(&directory)),
1202 );
1203 let mut sink =
1204 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1205 .unwrap();
1206 let mut quote = sample_quote();
1207 quote.bid_price = ERROR_PRICE;
1208 let write_error = sink.write_any("e).unwrap_err().to_string();
1209 let flush_error = sink.flush().unwrap_err().to_string();
1210 assert_eq!(flush_error, write_error);
1211 }
1212
1213 #[rstest]
1214 fn parquet_streaming_instrument_promotes() {
1215 use nautilus_model::instruments::{InstrumentAny, stubs::audusd_sim};
1216 let directory = TempDir::new().unwrap();
1217
1218 let config = WriterConnectConfig::new(
1219 directory
1220 .path()
1221 .join("backtest/run-instrument")
1222 .to_string_lossy(),
1223 Some(local_catalog(&directory)),
1224 );
1225 let mut sink =
1226 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1227 .unwrap();
1228 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1229 assert!(sink.write_any(&instrument).unwrap());
1230 sink.close().unwrap();
1231 let mut catalog = ParquetDataCatalog::from_uri(
1232 directory.path().to_str().unwrap(),
1233 None,
1234 None,
1235 None,
1236 None,
1237 )
1238 .unwrap();
1239 let rows = catalog
1240 .query_batch(&CatalogQuery::new(NautilusDataType::Instrument))
1241 .unwrap();
1242
1243 let DataBatch::Instrument(rows) = rows else {
1244 panic!("expected instruments")
1245 };
1246
1247 assert_eq!(rows.as_ref(), &[instrument]);
1248 }
1249
1250 #[rstest]
1251 fn parquet_promotion_preserves_every_record_family(#[values(false, true)] automatic: bool) {
1252 use nautilus_core::UUID4;
1253 use nautilus_model::{
1254 data::greeks::{GreeksData, OptionGreekValues, PortfolioGreeks},
1255 enums::{
1256 LiquiditySide, OrderSide, OrderStatus, OrderType, PositionAdjustmentType,
1257 PositionSide, TimeInForce,
1258 },
1259 events::{
1260 OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied, OrderEmulated,
1261 OrderExpired, OrderInitialized, OrderModifyRejected, OrderPendingCancel,
1262 OrderPendingUpdate, OrderRejected, OrderReleased, OrderSnapshot, OrderSubmitted,
1263 OrderTriggered, OrderUpdated, PositionAdjusted, PositionChanged, PositionClosed,
1264 PositionOpened, PositionSnapshot,
1265 account::stubs::cash_account_state,
1266 order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
1267 },
1268 identifiers::{
1269 AccountId, ClientId, ClientOrderId, PositionId, TradeId, Venue, VenueOrderId,
1270 },
1271 instruments::{InstrumentAny, stubs::audusd_sim},
1272 orders::OrderTestBuilder,
1273 position::Position,
1274 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
1275 stubs::TestDefault,
1276 types::Money,
1277 };
1278 use strum::IntoEnumIterator;
1279 let mut families = ahash::AHashSet::new();
1280 macro_rules! promote {
1281 ($record:expr) => {
1282 families.insert(assert_record_promotion($record, automatic));
1283 };
1284 }
1285
1286 let greeks = GreeksData::new(
1287 "ESZ26 C7000.XCME".parse().unwrap(),
1288 false,
1289 7000.5,
1290 20_261_218,
1291 68,
1292 0.186,
1293 50.0,
1294 -3.0,
1295 6950.25,
1296 0.0425,
1297 0.0125,
1298 0.175,
1299 12.5,
1300 98.75,
1301 OptionGreekValues {
1302 delta: 0.41,
1303 gamma: 0.0012,
1304 vega: 11.2,
1305 theta: -2.3,
1306 rho: 1.7,
1307 },
1308 0.62,
1309 11.into(),
1310 13.into(),
1311 );
1312
1313 let mut second = greeks.clone();
1314 second.ts_init = 113.into();
1315 second.instrument_id = "ESZ26 P6900.XCME".parse().unwrap();
1316 families.insert(assert_native_record_promotion(&[greeks, second], automatic));
1317
1318 let portfolio = PortfolioGreeks {
1319 pnl: -125.5,
1320 price: 4310.25,
1321 greeks: OptionGreekValues {
1322 delta: 0.83,
1323 gamma: 0.004,
1324 vega: 45.5,
1325 theta: -12.25,
1326 rho: 3.75,
1327 },
1328 ts_event: 19.into(),
1329 ts_init: 17.into(),
1330 };
1331
1332 let mut second = portfolio.clone();
1333 second.ts_init = 117.into();
1334 families.insert(assert_native_record_promotion(
1335 &[portfolio, second],
1336 automatic,
1337 ));
1338
1339 promote!(cash_account_state());
1340 promote!(OrderInitialized::test_default());
1341 promote!(OrderDenied::test_default());
1342 promote!(OrderEmulated::test_default());
1343 promote!(OrderSubmitted::test_default());
1344 promote!(OrderAccepted::test_default());
1345 promote!(OrderRejected::test_default());
1346 promote!(OrderPendingCancel::test_default());
1347 promote!(OrderCanceled::test_default());
1348 promote!(OrderCancelRejected::test_default());
1349 promote!(OrderExpired::test_default());
1350 promote!(OrderTriggered::test_default());
1351 promote!(OrderPendingUpdate::test_default());
1352 promote!(OrderReleased::test_default());
1353 promote!(OrderModifyRejected::test_default());
1354 promote!(OrderUpdated::test_default());
1355 promote!(OrderFillVoidedSpec::builder().is_reopened(true).build());
1356 let id = InstrumentId::from("AUD/USD.SIM");
1357 let fill = OrderFilledSpec::builder()
1358 .instrument_id(id)
1359 .position_id(PositionId::from("P-009"))
1360 .last_px(Price::from("1.23456"))
1361 .last_qty(Quantity::from("1234"))
1362 .ts_event(11.into())
1363 .ts_init(13.into())
1364 .build();
1365 promote!(fill.clone());
1366 let mut position = Position::new(&InstrumentAny::CurrencyPair(audusd_sim()), fill.clone());
1367 promote!(PositionOpened::create(
1368 &position,
1369 &fill,
1370 UUID4::default(),
1371 17.into()
1372 ));
1373 promote!(PositionChanged::create(
1374 &position,
1375 &fill,
1376 UUID4::default(),
1377 19.into()
1378 ));
1379 promote!(PositionAdjusted::new(
1380 fill.trader_id,
1381 fill.strategy_id,
1382 id,
1383 position.id,
1384 fill.account_id,
1385 PositionAdjustmentType::Funding,
1386 None,
1387 Some(Money::from("1.23 USD")),
1388 Some("funding".into()),
1389 UUID4::default(),
1390 23.into(),
1391 29.into()
1392 ));
1393 promote!(PositionSnapshot::from(
1394 &position,
1395 Some(Money::from("4.56 USD"))
1396 ));
1397 let closing = OrderFilledSpec::builder()
1398 .instrument_id(id)
1399 .position_id(position.id)
1400 .order_side(OrderSide::Sell)
1401 .last_px(Price::from("1.34567"))
1402 .last_qty(fill.last_qty)
1403 .trade_id(TradeId::from("T-011"))
1404 .ts_event(31.into())
1405 .ts_init(37.into())
1406 .build();
1407 position.apply(&closing);
1408 promote!(PositionClosed::create(
1409 &position,
1410 &closing,
1411 UUID4::default(),
1412 41.into()
1413 ));
1414 promote!(OrderSnapshot::from(
1415 OrderTestBuilder::new(OrderType::Limit)
1416 .instrument_id(id)
1417 .price(Price::from("1.45678"))
1418 .quantity(Quantity::from("5678"))
1419 .build()
1420 ));
1421
1422 let order = OrderStatusReport::new(
1423 AccountId::from("SIM-007"),
1424 id,
1425 Some(ClientOrderId::from("O-013")),
1426 VenueOrderId::from("V-017"),
1427 Some(OrderSide::Buy),
1428 OrderType::Limit,
1429 TimeInForce::Gtc,
1430 OrderStatus::PartiallyFilled,
1431 Quantity::from("123"),
1432 Quantity::from("45"),
1433 43.into(),
1434 47.into(),
1435 53.into(),
1436 None,
1437 );
1438
1439 let report_fill = FillReport::new(
1440 AccountId::from("SIM-007"),
1441 id,
1442 VenueOrderId::from("V-017"),
1443 TradeId::from("T-019"),
1444 OrderSide::Buy,
1445 Quantity::from("45"),
1446 Price::from("1.56789"),
1447 Money::from("2.34 USD"),
1448 LiquiditySide::Maker,
1449 Some(ClientOrderId::from("O-013")),
1450 Some(PositionId::from("P-023")),
1451 59.into(),
1452 61.into(),
1453 None,
1454 );
1455
1456 let report_position = PositionStatusReport::new(
1457 AccountId::from("SIM-007"),
1458 id,
1459 PositionSide::Long,
1460 Quantity::from("789"),
1461 67.into(),
1462 71.into(),
1463 None,
1464 Some(PositionId::from("P-023")),
1465 Some(rust_decimal::Decimal::new(16789, 4)),
1466 );
1467
1468 let mut mass = ExecutionMassStatus::new(
1469 ClientId::from("CLIENT-029"),
1470 AccountId::from("SIM-007"),
1471 Venue::from("SIM"),
1472 73.into(),
1473 None,
1474 );
1475 mass.add_order_reports(vec![order.clone()]);
1476 mass.add_fill_reports(vec![report_fill.clone()]);
1477 mass.add_position_reports(vec![report_position.clone()]);
1478 promote!(order);
1479 promote!(report_fill);
1480 promote!(report_position);
1481 promote!(mass);
1482
1483 let expected = NautilusRecordType::iter()
1484 .filter(|family| {
1485 #[cfg(feature = "defi")]
1486 if *family == NautilusRecordType::Defi {
1487 return false;
1488 }
1489
1490 let _ = family;
1491 true
1492 })
1493 .collect::<ahash::AHashSet<_>>();
1494
1495 assert_eq!(families, expected);
1496 }
1497
1498 fn assert_native_record_promotion<T>(records: &[T], automatic: bool) -> NautilusRecordType
1499 where
1500 T: std::any::Any + EncodeToRecordBatch + DecodeTypedFromRecordBatch,
1501 {
1502 let directory = TempDir::new().unwrap();
1503 let staging = directory.path().join("backtest/run-native-record");
1504 let mut config =
1505 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1506 config.promote_on_close = automatic;
1507 let mut sink =
1508 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1509 .unwrap();
1510
1511 for record in records {
1512 assert!(sink.write_any(record).unwrap());
1513 }
1514
1515 sink.close().unwrap();
1516 let record_type = T::get_schema(None).metadata()["type_name"]
1517 .parse::<NautilusRecordType>()
1518 .unwrap();
1519 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
1520 if !automatic {
1521 catalog
1522 .convert_stream_to_data(
1523 "run-native-record",
1524 &record_type.into(),
1525 Environment::Backtest,
1526 None,
1527 false,
1528 )
1529 .unwrap();
1530 }
1531
1532 let batches = catalog
1533 .query_record_batches(&record_type.into(), None, None, None, None, true)
1534 .unwrap();
1535
1536 let decoded = batches
1537 .into_iter()
1538 .flat_map(|batch| {
1539 let schema = batch.schema();
1540 T::decode_typed_batch(schema.metadata(), batch).unwrap()
1541 })
1542 .collect::<Vec<_>>();
1543
1544 let expected = T::encode_batch(&records[0].metadata(), records).unwrap();
1545 let actual = T::encode_batch(&records[0].metadata(), &decoded).unwrap();
1546 assert_eq!(actual.columns(), expected.columns());
1547 record_type
1548 }
1549
1550 fn assert_record_promotion<T>(record: T, automatic: bool) -> NautilusRecordType
1551 where
1552 T: std::any::Any
1553 + serde::Serialize
1554 + serde::de::DeserializeOwned
1555 + EncodeToRecordBatch
1556 + DecodeTypedFromRecordBatch,
1557 {
1558 let directory = TempDir::new().unwrap();
1559 let staging = directory.path().join("backtest/run-record");
1560 let mut config =
1561 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1562 config.promote_on_close = automatic;
1563 let mut sink =
1564 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1565 .unwrap();
1566 let mut second = serde_json::to_value(&record).unwrap();
1567 second["ts_init"] = serde_json::json!(second["ts_init"].as_u64().unwrap() + 101);
1568 let second: T = serde_json::from_value(second).unwrap();
1569 assert!(sink.write_any(&record).unwrap());
1570 assert!(sink.write_any(&second).unwrap());
1571 sink.close().unwrap();
1572 let record_type = T::get_schema(None).metadata()["type_name"]
1573 .parse::<NautilusRecordType>()
1574 .unwrap();
1575 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
1576 if !automatic {
1577 catalog
1578 .convert_stream_to_data(
1579 "run-record",
1580 &record_type.into(),
1581 Environment::Backtest,
1582 None,
1583 false,
1584 )
1585 .unwrap();
1586 }
1587
1588 let batches = catalog
1589 .query_record_batches(&record_type.into(), None, None, None, None, true)
1590 .unwrap();
1591
1592 let actual = batches
1593 .into_iter()
1594 .flat_map(|batch| {
1595 let schema = batch.schema();
1596 T::decode_typed_batch(schema.metadata(), batch).unwrap()
1597 })
1598 .map(|record| serde_json::to_value(record).unwrap())
1599 .collect::<Vec<_>>();
1600
1601 assert_eq!(
1602 actual,
1603 vec![
1604 serde_json::to_value(record).unwrap(),
1605 serde_json::to_value(second).unwrap()
1606 ]
1607 );
1608 record_type
1609 }
1610
1611 #[rstest]
1612 fn parquet_streaming_voided_fill_promotes() {
1613 use nautilus_model::events::{OrderFillVoided, order::spec::OrderFillVoidedSpec};
1614 use nautilus_serialization::arrow::DecodeTypedFromRecordBatch;
1615 let directory = TempDir::new().unwrap();
1616
1617 let config = WriterConnectConfig::new(
1618 directory.path().join("backtest/run-void").to_string_lossy(),
1619 Some(local_catalog(&directory)),
1620 );
1621 let mut sink =
1622 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1623 .unwrap();
1624 let event = OrderFillVoidedSpec::builder().is_reopened(true).build();
1625 assert!(sink.write_any(&event).unwrap());
1626 sink.close().unwrap();
1627 let mut catalog = ParquetDataCatalog::from_uri(
1628 directory.path().to_str().unwrap(),
1629 None,
1630 None,
1631 None,
1632 None,
1633 )
1634 .unwrap();
1635 let batches = catalog
1636 .query_record_batches(
1637 &NautilusRecordType::OrderFillVoided.into(),
1638 None,
1639 None,
1640 None,
1641 None,
1642 true,
1643 )
1644 .unwrap();
1645 let mut rows = Vec::new();
1646 for batch in batches {
1647 rows.extend(
1648 OrderFillVoided::decode_typed_batch(batch.schema().metadata(), batch.clone())
1649 .unwrap(),
1650 );
1651 }
1652
1653 assert_eq!(rows, vec![event]);
1654 }
1655
1656 #[rstest]
1657 fn parquet_promotion_preserves_clear_source_order(
1658 #[values(false, true)] automatic: bool,
1659 #[values(false, true)] batched: bool,
1660 #[values(false, true)] undefined_price: bool,
1661 ) {
1662 use nautilus_model::{
1663 data::{BookOrder, OrderBookDelta},
1664 enums::{BookAction, OrderSide, RecordFlag},
1665 types::PRICE_UNDEF,
1666 };
1667 let directory = TempDir::new().unwrap();
1668 let staging = directory.path().join("backtest/run-clear");
1669 let mut config =
1670 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1671 config.promote_on_close = automatic;
1672 let mut sink =
1673 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1674 .unwrap();
1675 let id = InstrumentId::from("AUD/USD.SIM");
1676 let ts = UnixNanos::from(1_700_000_000_000_000_123);
1677 let mut clear = OrderBookDelta::clear(id, 7, ts, ts);
1678 if undefined_price {
1679 clear.order.price = Price::from_raw(PRICE_UNDEF, 0);
1680 }
1681
1682 let add = OrderBookDelta::new(
1683 id,
1684 BookAction::Add,
1685 BookOrder::new(
1686 OrderSide::Buy,
1687 Price::from("1.23456"),
1688 Quantity::from("7.890"),
1689 19,
1690 ),
1691 RecordFlag::F_LAST as u8 | RecordFlag::F_SNAPSHOT as u8,
1692 7,
1693 ts,
1694 ts,
1695 );
1696 let mut second_add = add;
1697 second_add.order.order_id = 29;
1698 second_add.order.price = Price::from("1.34567");
1699 second_add.order.size = Quantity::from("8.901");
1700 let rows = vec![clear, add, clear, second_add];
1701 if batched {
1702 sink.write_batch(rows.iter().copied().map(Data::BookDelta).collect())
1703 .unwrap();
1704 } else {
1705 for row in &rows {
1706 sink.write_data(Data::BookDelta(*row)).unwrap();
1707 }
1708 }
1709
1710 sink.close().unwrap();
1711 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
1712 let storage = create_storage_backend_from_path(staging.to_str().unwrap(), None).unwrap();
1713 let staged = block_on_nautilus_with(|| storage.list_files("", Some(".feather"))).unwrap();
1714 let mut originals = Vec::new();
1715
1716 for path in &staged {
1717 let bytes = block_on_nautilus_with(|| async {
1718 storage
1719 .object_store
1720 .get(&object_store::path::Path::from(path.as_str()))
1721 .await
1722 .unwrap()
1723 .bytes()
1724 .await
1725 .unwrap()
1726 });
1727
1728 originals.push(bytes);
1729 }
1730
1731 if !automatic {
1732 catalog
1733 .convert_stream_to_data(
1734 "run-clear",
1735 &NautilusDataType::OrderBookDelta.into(),
1736 Environment::Backtest,
1737 None,
1738 false,
1739 )
1740 .unwrap();
1741 }
1742
1743 let actual = catalog
1744 .query::<OrderBookDelta>(None, None, None, None, None, true)
1745 .unwrap();
1746 assert_eq!(actual.len(), rows.len());
1747
1748 for (actual, expected) in actual.iter().zip(&rows) {
1749 assert_eq!(actual.instrument_id, expected.instrument_id);
1750 assert_eq!(actual.action, expected.action);
1751 assert_eq!(actual.order.side, expected.order.side);
1752 assert_eq!(actual.order.price.raw(), expected.order.price.raw());
1753
1754 let price_precision = if expected.order.price.is_undefined() {
1755 0
1756 } else {
1757 5
1758 };
1759
1760 assert_eq!(actual.order.price.precision, price_precision);
1761 assert_eq!(
1762 actual.order.size.as_decimal(),
1763 expected.order.size.as_decimal()
1764 );
1765 assert_eq!(actual.order.size.precision, 3);
1766 assert_eq!(actual.order.order_id, expected.order.order_id);
1767 assert_eq!(actual.flags, expected.flags);
1768 assert_eq!(actual.sequence, expected.sequence);
1769 assert_eq!(actual.ts_event, expected.ts_event);
1770 assert_eq!(actual.ts_init, expected.ts_init);
1771 }
1772
1773 for (path, original) in staged.iter().zip(originals) {
1774 let bytes = block_on_nautilus_with(|| async {
1775 storage
1776 .object_store
1777 .get(&object_store::path::Path::from(path.as_str()))
1778 .await
1779 .unwrap()
1780 .bytes()
1781 .await
1782 .unwrap()
1783 });
1784
1785 assert_eq!(bytes, original);
1786 }
1787
1788 assert_eq!(staged.len(), 1);
1789 }
1790
1791 #[rstest]
1792 #[case::manual(false, false)]
1793 #[case::automatic_retained(true, false)]
1794 #[case::automatic_delete(true, true)]
1795 fn parquet_failed_clear_promotion_preserves_source(
1796 #[case] automatic: bool,
1797 #[case] delete_source: bool,
1798 #[values(false, true)] batched: bool,
1799 ) {
1800 use nautilus_model::{
1801 data::{BookOrder, OrderBookDelta},
1802 enums::{BookAction, OrderSide, RecordFlag},
1803 };
1804 let directory = TempDir::new().unwrap();
1805 let staging = directory.path().join("backtest/run-invalid-clear");
1806 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
1807
1808 let mut writer = FeatherWriter::new(
1809 staging.clone(),
1810 clock.clone(),
1811 RotationConfig::NoRotation,
1812 None,
1813 None,
1814 );
1815 let id = InstrumentId::from("AUD/USD.SIM");
1816 let ts = UnixNanos::from(23);
1817 let mut clear = OrderBookDelta::clear(id, 7, ts, ts);
1818 clear.order.price = Price::from("1");
1819
1820 let add = OrderBookDelta::new(
1821 id,
1822 BookAction::Add,
1823 BookOrder::new(
1824 OrderSide::Buy,
1825 Price::from("1.23456"),
1826 Quantity::from("7.890"),
1827 19,
1828 ),
1829 RecordFlag::F_LAST as u8,
1830 7,
1831 ts,
1832 ts,
1833 );
1834
1835 if batched {
1836 writer.write_batch(vec![clear, add]).unwrap();
1837 } else {
1838 writer.write(clear).unwrap();
1839 writer.write(add).unwrap();
1840 }
1841
1842 writer.close().unwrap();
1843 let source = staging.join("order_book_deltas/order_book_deltas_0.feather");
1844 let original = fs::read(&source).unwrap();
1845 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
1846
1847 let failure = if automatic {
1848 let mut config = WriterConnectConfig::new(
1849 staging.to_string_lossy(),
1850 Some(local_catalog(&directory)),
1851 );
1852 config.delete_feather_after_promotion = delete_source;
1853 let mut sink = parquet_writer_factory(&config, clock).unwrap();
1854 sink.close().unwrap_err()
1855 } else {
1856 catalog
1857 .convert_stream_to_data(
1858 "run-invalid-clear",
1859 &NautilusDataType::OrderBookDelta.into(),
1860 Environment::Backtest,
1861 None,
1862 false,
1863 )
1864 .unwrap_err()
1865 };
1866
1867 assert!(
1868 format!("{failure:#}").contains(
1869 "cannot relabel precision metadata for a promotion group that contains decimal values"
1870 ),
1871 "{failure:#}"
1872 );
1873 assert_eq!(fs::read(source).unwrap(), original);
1874 assert_eq!(
1875 catalog
1876 .query_files(&NautilusDataType::OrderBookDelta.into(), None, None, None)
1877 .unwrap(),
1878 Vec::<String>::new()
1879 );
1880 }
1881
1882 #[rstest]
1883 #[case(true)]
1884 #[case(false)]
1885 fn parquet_promotion_unifies_empty_and_populated_depth(#[case] automatic: bool) {
1886 use nautilus_model::{
1887 data::{BookOrder, OrderBookDepth},
1888 enums::OrderSide,
1889 };
1890 let directory = TempDir::new().unwrap();
1891 let staging = directory.path().join("backtest/run-depth-ties");
1892 let mut config =
1893 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1894 config.delete_feather_after_promotion = true;
1895 config.promote_on_close = automatic;
1896 let mut sink =
1897 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1898 .unwrap();
1899 let id = InstrumentId::from("AUD/USD.SIM");
1900 let empty =
1901 OrderBookDepth::new(id, vec![], vec![], vec![], vec![], 1, 2, 3.into(), 4.into());
1902 let order = BookOrder::new(
1903 OrderSide::Buy,
1904 Price::from("1.23"),
1905 Quantity::from("4.5"),
1906 6,
1907 );
1908 let populated = OrderBookDepth::new(
1909 id,
1910 vec![order],
1911 vec![],
1912 vec![7],
1913 vec![],
1914 8,
1915 9,
1916 3.into(),
1917 4.into(),
1918 );
1919 sink.write_any(&empty).unwrap();
1920 sink.write_any(&populated).unwrap();
1921 sink.close().unwrap();
1922
1923 let mut catalog = ParquetDataCatalog::from_uri(
1924 directory.path().to_str().unwrap(),
1925 None,
1926 None,
1927 None,
1928 None,
1929 )
1930 .unwrap();
1931
1932 if !automatic {
1933 catalog
1934 .convert_stream_to_data(
1935 "run-depth-ties",
1936 &NautilusDataType::OrderBookDepth.into(),
1937 Environment::Backtest,
1938 None,
1939 false,
1940 )
1941 .unwrap();
1942 }
1943
1944 let batch = catalog
1945 .query_batch(&CatalogQuery::new(NautilusDataType::OrderBookDepth))
1946 .unwrap();
1947 let DataBatch::BookDepth(rows) = batch else {
1948 panic!("expected order book depth");
1949 };
1950 let mut rows = rows.as_ref().to_vec();
1951 rows.sort_by_key(|depth| depth.sequence);
1952 assert_eq!(rows, vec![empty, populated]);
1953
1954 let storage = create_storage_backend_from_path(staging.to_str().unwrap(), None).unwrap();
1955 let staged = block_on_nautilus_with(|| storage.list_files("", Some(".feather"))).unwrap();
1956 assert_eq!(staged.len(), usize::from(!automatic));
1957 }
1958
1959 #[rstest]
1960 fn parquet_promotion_preserves_clear_snapshots(
1961 #[values(false, true)] automatic: bool,
1962 #[values(false, true)] batch_write: bool,
1963 ) {
1964 let directory = TempDir::new().unwrap();
1965 let staging = directory.path().join("backtest").join("run-clear");
1966 let mut config =
1967 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
1968 config.promote_on_close = automatic;
1969 let mut sink =
1970 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
1971 .unwrap();
1972 let id = InstrumentId::from("AUD/USD.SIM");
1973 let mut deltas = Vec::new();
1974 for (sequence, timestamp, price, size) in [
1975 (41, 100, "1.23", "4.500"),
1976 (42, 200, "1.24", "6.700"),
1977 (43, 300, "1.2345", "8.90000"),
1978 ] {
1979 deltas.push(OrderBookDelta::clear(
1980 id,
1981 sequence,
1982 (timestamp - 1).into(),
1983 timestamp.into(),
1984 ));
1985
1986 for (side, order_id, flags) in [
1987 (OrderSide::Buy, 71, RecordFlag::F_SNAPSHOT as u8),
1988 (
1989 OrderSide::Sell,
1990 72,
1991 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8,
1992 ),
1993 ] {
1994 deltas.push(OrderBookDelta::new(
1995 id,
1996 BookAction::Add,
1997 BookOrder::new(side, Price::from(price), Quantity::from(size), order_id),
1998 flags,
1999 sequence,
2000 (timestamp - 1).into(),
2001 timestamp.into(),
2002 ));
2003 }
2004 }
2005
2006 deltas.push(OrderBookDelta::clear(id, 44, 399.into(), 400.into()));
2007 if batch_write {
2008 sink.write_batch(deltas.iter().copied().map(Data::from).collect())
2009 .unwrap();
2010 } else {
2011 for delta in &deltas {
2012 sink.write_data(Data::BookDelta(*delta)).unwrap();
2013 }
2014 }
2015
2016 sink.close().unwrap();
2017 let mut catalog = ParquetDataCatalog::from_uri(
2018 directory.path().to_str().unwrap(),
2019 None,
2020 None,
2021 None,
2022 None,
2023 )
2024 .unwrap();
2025
2026 if !automatic {
2027 catalog
2028 .convert_stream_to_data(
2029 "run-clear",
2030 &NautilusDataType::OrderBookDelta.into(),
2031 Environment::Backtest,
2032 None,
2033 false,
2034 )
2035 .unwrap();
2036 }
2037
2038 let batch = catalog
2039 .query_batch(&CatalogQuery::new(NautilusDataType::OrderBookDelta))
2040 .unwrap();
2041
2042 let DataBatch::BookDelta(rows) = batch else {
2043 panic!("expected order book deltas");
2044 };
2045
2046 assert_eq!(rows.len(), deltas.len());
2047
2048 for (actual, expected) in rows.as_ref().iter().zip(&deltas) {
2049 let (price_precision, size_precision) = if expected.sequence < 43 {
2050 (2, 3)
2051 } else {
2052 (4, 5)
2053 };
2054
2055 assert_eq!(actual.instrument_id, expected.instrument_id);
2056 assert_eq!(actual.action, expected.action);
2057 assert_eq!(actual.order.side, expected.order.side);
2058 assert_eq!(actual.order.price.raw(), expected.order.price.raw());
2059 assert_eq!(actual.order.price.precision, price_precision);
2060 assert_eq!(actual.order.size.raw(), expected.order.size.raw());
2061 assert_eq!(actual.order.size.precision, size_precision);
2062 assert_eq!(actual.order.order_id, expected.order.order_id);
2063 assert_eq!(actual.flags, expected.flags);
2064 assert_eq!(actual.sequence, expected.sequence);
2065 assert_eq!(actual.ts_event, expected.ts_event);
2066 assert_eq!(actual.ts_init, expected.ts_init);
2067 }
2068 }
2069
2070 #[rstest]
2071 #[case::real_add(BookAction::Add, Some(OrderSide::Buy), "1", "2", 0)]
2072 #[case::clear_side(BookAction::Clear, Some(OrderSide::Buy), "0", "0", 0)]
2073 #[case::clear_price(BookAction::Clear, None, "1", "0", 0)]
2074 #[case::clear_size(BookAction::Clear, None, "0", "1", 0)]
2075 #[case::clear_order_id(BookAction::Clear, None, "0", "0", 1)]
2076 fn parquet_promotion_rejects_noncanonical_zero_precision_orders(
2077 #[case] action: BookAction,
2078 #[case] side: Option<OrderSide>,
2079 #[case] price: &str,
2080 #[case] size: &str,
2081 #[case] order_id: u64,
2082 #[values(false, true)] automatic: bool,
2083 ) {
2084 let directory = TempDir::new().unwrap();
2085 let staging = directory.path().join("backtest").join("run-clear-invalid");
2086 let mut config =
2087 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
2088 config.promote_on_close = automatic;
2089 let mut sink =
2090 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
2091 .unwrap();
2092 let id = InstrumentId::from("AUD/USD.SIM");
2093 let deltas = vec![
2094 OrderBookDelta::clear(id, 41, 99.into(), 100.into()),
2095 OrderBookDelta::new(
2096 id,
2097 action,
2098 BookOrder {
2099 side,
2100 price: Price::from(price),
2101 size: Quantity::from(size),
2102 order_id,
2103 },
2104 0,
2105 41,
2106 99.into(),
2107 100.into(),
2108 ),
2109 OrderBookDelta::new(
2110 id,
2111 BookAction::Add,
2112 BookOrder::new(
2113 OrderSide::Sell,
2114 Price::from("1.23"),
2115 Quantity::from("4.500"),
2116 72,
2117 ),
2118 RecordFlag::F_LAST as u8,
2119 41,
2120 99.into(),
2121 100.into(),
2122 ),
2123 ];
2124 sink.write_batch(deltas.into_iter().map(Data::from).collect())
2125 .unwrap();
2126 let mut catalog = ParquetDataCatalog::from_uri(
2127 directory.path().to_str().unwrap(),
2128 None,
2129 None,
2130 None,
2131 None,
2132 )
2133 .unwrap();
2134
2135 let error = if automatic {
2136 sink.close().unwrap_err()
2137 } else {
2138 sink.close().unwrap();
2139 catalog
2140 .convert_stream_to_data(
2141 "run-clear-invalid",
2142 &NautilusDataType::OrderBookDelta.into(),
2143 Environment::Backtest,
2144 None,
2145 false,
2146 )
2147 .unwrap_err()
2148 };
2149
2150 assert!(error.to_string().contains(
2151 "cannot relabel precision metadata for a promotion group that contains decimal values"
2152 ));
2153 assert!(
2154 catalog
2155 .query_files(&NautilusDataType::OrderBookDelta.into(), None, None, None)
2156 .unwrap()
2157 .is_empty()
2158 );
2159 }
2160
2161 #[rstest]
2162 fn parquet_manual_conversion_promotes_custom_data() {
2163 use nautilus_model::data::{CustomData, DataType};
2164 use nautilus_serialization::ensure_custom_data_registered;
2165
2166 ensure_custom_data_registered::<RustTestHashMapCustomData>();
2167
2168 let directory = TempDir::new().unwrap();
2169 let staging = directory.path().join("backtest").join("run-custom");
2170 let mut config =
2171 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
2172 config.promote_on_close = false;
2173 let mut sink =
2174 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
2175 .unwrap();
2176
2177 let data_type = DataType::new("RustTestHashMapCustomData", None, None);
2178 let records = [
2179 sample_custom_data("first", "AUD/USD.SIM", "1.23456", 11),
2180 sample_custom_data("second", "BTCUSDT.BINANCE", "65432.10", 21),
2181 ];
2182
2183 for record in &records {
2184 sink.write_data(Data::Custom(CustomData::new(
2185 Arc::new(record.clone()),
2186 data_type.clone(),
2187 )))
2188 .unwrap();
2189 }
2190
2191 sink.close().unwrap();
2192
2193 let mut catalog = ParquetDataCatalog::from_uri(
2194 directory.path().to_str().unwrap(),
2195 None,
2196 None,
2197 None,
2198 None,
2199 )
2200 .unwrap();
2201
2202 for _ in 0..2 {
2203 catalog
2204 .convert_stream_to_data(
2205 "run-custom",
2206 &NautilusDataType::Custom {
2207 type_name: "RustTestHashMapCustomData".to_string(),
2208 }
2209 .into(),
2210 Environment::Backtest,
2211 None,
2212 false,
2213 )
2214 .unwrap();
2215 }
2216
2217 let expected: Vec<(Option<String>, RustTestHashMapCustomData)> = records
2218 .iter()
2219 .map(|record| (None, record.clone()))
2220 .collect();
2221 assert_eq!(query_custom_records(&mut catalog, None), expected);
2222 }
2223
2224 #[rstest]
2225 fn parquet_manual_conversion_filters_custom_data_identifiers() {
2226 use nautilus_model::data::{CustomData, DataType};
2227 use nautilus_serialization::ensure_custom_data_registered;
2228
2229 ensure_custom_data_registered::<RustTestHashMapCustomData>();
2230
2231 let directory = TempDir::new().unwrap();
2232 let staging = directory.path().join("backtest").join("run-custom-ids");
2233 let mut config =
2234 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
2235 config.promote_on_close = false;
2236 let mut sink =
2237 parquet_writer_factory(&config, WriterClock::Test(Arc::new(AtomicU64::new(0))))
2238 .unwrap();
2239
2240 let audusd = "AUD/USD.SIM";
2241 let btcusdt = "BTCUSDT.BINANCE";
2242 let records = [
2243 (audusd, sample_custom_data("first", audusd, "1.23456", 11)),
2244 (audusd, sample_custom_data("second", audusd, "1.23457", 21)),
2245 (
2246 btcusdt,
2247 sample_custom_data("third", btcusdt, "65432.10", 31),
2248 ),
2249 ];
2250
2251 for (identifier, record) in &records {
2252 let data_type = DataType::new(
2253 "RustTestHashMapCustomData",
2254 None,
2255 Some((*identifier).to_string()),
2256 );
2257 sink.write_data(Data::Custom(CustomData::new(
2258 Arc::new(record.clone()),
2259 data_type,
2260 )))
2261 .unwrap();
2262 }
2263
2264 sink.close().unwrap();
2265
2266 let mut catalog = ParquetDataCatalog::from_uri(
2267 directory.path().to_str().unwrap(),
2268 None,
2269 None,
2270 None,
2271 None,
2272 )
2273 .unwrap();
2274 catalog
2275 .convert_stream_to_data(
2276 "run-custom-ids",
2277 &NautilusDataType::Custom {
2278 type_name: "RustTestHashMapCustomData".to_string(),
2279 }
2280 .into(),
2281 Environment::Backtest,
2282 Some(&[audusd.to_string()]),
2283 false,
2284 )
2285 .unwrap();
2286
2287 let expected_audusd: Vec<(Option<String>, RustTestHashMapCustomData)> = records
2288 .iter()
2289 .filter(|(identifier, _)| *identifier == audusd)
2290 .map(|(identifier, record)| (Some((*identifier).to_string()), record.clone()))
2291 .collect();
2292 assert_eq!(query_custom_records(&mut catalog, None), expected_audusd);
2293
2294 assert_eq!(
2297 query_custom_records(&mut catalog, Some(&["AUDUSD.SIM".to_string()])),
2298 expected_audusd,
2299 );
2300
2301 catalog
2302 .convert_stream_to_data(
2303 "run-custom-ids",
2304 &NautilusDataType::Custom {
2305 type_name: "RustTestHashMapCustomData".to_string(),
2306 }
2307 .into(),
2308 Environment::Backtest,
2309 None,
2310 false,
2311 )
2312 .unwrap();
2313
2314 let expected_all: Vec<(Option<String>, RustTestHashMapCustomData)> = records
2315 .iter()
2316 .map(|(identifier, record)| (Some((*identifier).to_string()), record.clone()))
2317 .collect();
2318 assert_eq!(query_custom_records(&mut catalog, None), expected_all);
2319 let expected_btcusdt: Vec<(Option<String>, RustTestHashMapCustomData)> = records
2320 .iter()
2321 .filter(|(identifier, _)| *identifier == btcusdt)
2322 .map(|(identifier, record)| (Some((*identifier).to_string()), record.clone()))
2323 .collect();
2324 assert_eq!(
2325 query_custom_records(&mut catalog, Some(&[btcusdt.to_string()])),
2326 expected_btcusdt,
2327 );
2328 }
2329
2330 fn sample_custom_data(
2331 name: &str,
2332 instrument_id: &str,
2333 price: &str,
2334 ts: u64,
2335 ) -> RustTestHashMapCustomData {
2336 RustTestHashMapCustomData {
2337 name: name.to_string(),
2338 prices: [(instrument_id.to_string(), Price::from(price))]
2339 .into_iter()
2340 .collect(),
2341 ts_event: UnixNanos::from(ts - 1),
2342 ts_init: UnixNanos::from(ts),
2343 }
2344 }
2345
2346 fn query_custom_records(
2347 catalog: &mut ParquetDataCatalog,
2348 identifiers: Option<&[String]>,
2349 ) -> Vec<(Option<String>, RustTestHashMapCustomData)> {
2350 let loaded = catalog
2351 .query_custom_data_dynamic(
2352 "RustTestHashMapCustomData",
2353 identifiers,
2354 None,
2355 None,
2356 None,
2357 None,
2358 true,
2359 )
2360 .unwrap();
2361 let mut decoded = Vec::new();
2362
2363 for data in &loaded {
2364 let Data::Custom(custom) = data else {
2365 panic!("expected custom data, found {data:?}")
2366 };
2367
2368 assert_eq!(custom.data_type.type_name(), "RustTestHashMapCustomData");
2369 decoded.push((
2370 custom.data_type.identifier().map(ToString::to_string),
2371 custom
2372 .data
2373 .as_any()
2374 .downcast_ref::<RustTestHashMapCustomData>()
2375 .expect("expected RustTestHashMapCustomData")
2376 .clone(),
2377 ));
2378 }
2379
2380 decoded.sort_by_key(|(_, record)| record.ts_init);
2381 decoded
2382 }
2383
2384 fn local_catalog(directory: &TempDir) -> CatalogConnectConfig {
2385 CatalogConnectConfig::new(directory.path().to_string_lossy(), None)
2386 }
2387
2388 fn stage_per_identifier_quote(staging: &Path) -> (QuoteTick, PathBuf) {
2390 let mut quote = sample_quote();
2391 quote.instrument_id = InstrumentId::from("CAFÉ.SIM");
2392
2393 let mut writer = FeatherWriter::new(
2394 staging.to_path_buf(),
2395 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2396 RotationConfig::NoRotation,
2397 None,
2398 None,
2399 );
2400 writer.write(quote).unwrap();
2401 writer.close().unwrap();
2402
2403 let identifier_directory = staging.join("quotes").join("CAFÉ.SIM");
2404 let staged_file = identifier_directory.join("quotes_0.feather");
2405 fs::create_dir(&identifier_directory).unwrap();
2406 fs::rename(
2407 staging.join("quotes").join("quotes_0.feather"),
2408 &staged_file,
2409 )
2410 .unwrap();
2411
2412 (quote, staged_file)
2413 }
2414
2415 fn sample_quote() -> QuoteTick {
2416 QuoteTick::new(
2417 InstrumentId::from("AUD/USD.SIM"),
2418 Price::from("0.65"),
2419 Price::from("0.67"),
2420 Quantity::from("11"),
2421 Quantity::from("17"),
2422 UnixNanos::from(19),
2423 UnixNanos::from(23),
2424 )
2425 }
2426
2427 #[rstest]
2428 fn kept_promotions_release_scheduled_paths() {
2429 let directory = TempDir::new().unwrap();
2430 let config = WriterConnectConfig::new(
2431 directory.path().join("live/run-bounded").to_string_lossy(),
2432 Some(local_catalog(&directory)),
2433 );
2434 let mut writer = ParquetWriter::new(&config, WriterClock::Live).unwrap();
2435
2436 for timestamp in 1..=32 {
2437 writer
2438 .core
2439 .write_data(Data::Quote(QuoteTick {
2440 ts_event: UnixNanos::from(timestamp),
2441 ts_init: UnixNanos::from(timestamp),
2442 ..sample_quote()
2443 }))
2444 .unwrap();
2445 let work = writer.prepare_promotion().unwrap().unwrap();
2446 assert_eq!(work.files().len(), 1);
2447 writer.finalize(work.execute()).unwrap();
2448 assert_scheduled_count(&writer.core.promotion_driver.schedule(), 0);
2449 assert!(writer.prepare_promotion().unwrap().is_none());
2450 }
2451
2452 assert_eq!(
2453 list_session_feather_files(&writer.source.storage, Environment::Live, "run-bounded")
2454 .unwrap()
2455 .len(),
2456 32
2457 );
2458 }
2459
2460 #[rstest]
2461 #[case::timer_seal(RotationConfig::NoRotation, false, 1)]
2462 #[case::close(RotationConfig::NoRotation, true, 1)]
2463 #[case::rotation(RotationConfig::Size { max_size: 1 }, false, 2)]
2464 fn sealed_paths_promote_once_and_do_not_resubmit_in_flight(
2465 #[case] rotation: RotationConfig,
2466 #[case] close: bool,
2467 #[case] expected_files: usize,
2468 ) {
2469 let directory = TempDir::new().unwrap();
2470 let mut config = WriterConnectConfig::new(
2471 directory.path().join("live/run-sealing").to_string_lossy(),
2472 Some(local_catalog(&directory)),
2473 );
2474 config.rotation_config = rotation;
2475 let mut writer = ParquetWriter::new(&config, WriterClock::Live).unwrap();
2476 let first = sample_quote();
2477 let second = QuoteTick {
2478 ts_event: UnixNanos::from(29),
2479 ts_init: UnixNanos::from(31),
2480 ..first
2481 };
2482 writer.core.write_data(Data::Quote(first)).unwrap();
2483 writer.core.write_data(Data::Quote(second)).unwrap();
2484
2485 if close {
2486 writer.core.close().unwrap();
2487 }
2488
2489 let work = writer.prepare_promotion().unwrap().unwrap();
2490 let files = work.files().to_vec();
2491 assert_eq!(files.len(), expected_files);
2492 assert!(writer.prepare_promotion().unwrap().is_none());
2493 assert_scheduled_count(&writer.core.promotion_driver.schedule(), expected_files);
2494 let result = work.execute();
2495 assert_scheduled_count(&writer.core.promotion_driver.schedule(), 0);
2496 assert_eq!(result.committed_paths, files);
2497 assert_eq!(writer.finalize(result).unwrap().len(), expected_files);
2498 assert!(writer.prepare_promotion().unwrap().is_none());
2499
2500 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2501 let DataBatch::Quote(rows) = catalog
2502 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
2503 .unwrap()
2504 else {
2505 panic!("expected quotes")
2506 };
2507 assert_eq!(rows.as_ref(), &[first, second]);
2508 }
2509
2510 #[rstest]
2511 #[case::space("my data")]
2512 #[case::non_ascii("Données")]
2513 #[case::fragment("data#1")]
2514 #[case::literal_percent("data%20")]
2515 fn writer_root_path_promotes_and_replays(
2516 #[case] root_name: &str,
2517 #[values(false, true)] file_uri: bool,
2518 ) {
2519 let directory = TempDir::new().unwrap();
2520 let root = directory.path().join(root_name);
2521 let staging = root.join("live/run-root");
2522 let staging_uri = if file_uri {
2523 normalize_path_to_uri(&staging.to_string_lossy()).unwrap()
2524 } else {
2525 staging.to_string_lossy().into_owned()
2526 };
2527 let config = WriterConnectConfig::new(staging_uri, Some(local_catalog(&directory)));
2528 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2529 let quote = sample_quote();
2530 let mut writer = ParquetWriter::new(&config, clock.clone()).unwrap();
2531 writer.core.write_data(Data::Quote(quote)).unwrap();
2532 let work = writer.prepare_promotion().unwrap().unwrap();
2533 assert_eq!(work.files(), &["live/run-root/quotes/quotes_0.feather"]);
2534 assert_eq!(writer.finalize(work.execute()).unwrap().len(), 1);
2535 assert!(writer.prepare_promotion().unwrap().is_none());
2536 drop(writer);
2537
2538 let mut restarted = ParquetWriter::new(&config, clock).unwrap();
2539 let work = restarted.prepare_promotion().unwrap().unwrap();
2540 assert_eq!(work.files(), &["live/run-root/quotes/quotes_0.feather"]);
2541 assert!(restarted.finalize(work.execute()).unwrap().is_empty());
2542 assert!(restarted.prepare_promotion().unwrap().is_none());
2543
2544 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2545 let DataBatch::Quote(rows) = catalog
2546 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
2547 .unwrap()
2548 else {
2549 panic!("expected quotes")
2550 };
2551 assert_eq!(rows.as_ref(), &[quote]);
2552 }
2553
2554 #[rstest]
2555 fn parent_relative_writer_path_promotes_and_replays() {
2556 let directory = TempDir::new_in(".").unwrap();
2557 let current = std::env::current_dir().unwrap();
2558 let staging = PathBuf::from("..")
2559 .join(current.file_name().unwrap())
2560 .join(directory.path().file_name().unwrap())
2561 .join("live/run-relative");
2562 let config =
2563 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
2564 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2565 let quote = sample_quote();
2566 let mut writer = ParquetWriter::new(&config, clock.clone()).unwrap();
2567 writer.core.write_data(Data::Quote(quote)).unwrap();
2568 let work = writer.prepare_promotion().unwrap().unwrap();
2569 assert_eq!(work.files(), &["live/run-relative/quotes/quotes_0.feather"]);
2570 assert_eq!(writer.finalize(work.execute()).unwrap().len(), 1);
2571 assert!(writer.prepare_promotion().unwrap().is_none());
2572 drop(writer);
2573
2574 let mut restarted = ParquetWriter::new(&config, clock).unwrap();
2575 let work = restarted.prepare_promotion().unwrap().unwrap();
2576 assert_eq!(work.files(), &["live/run-relative/quotes/quotes_0.feather"]);
2577 assert!(restarted.finalize(work.execute()).unwrap().is_empty());
2578 assert!(restarted.prepare_promotion().unwrap().is_none());
2579
2580 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2581 let DataBatch::Quote(rows) = catalog
2582 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
2583 .unwrap()
2584 else {
2585 panic!("expected quotes")
2586 };
2587 assert_eq!(rows.as_ref(), &[quote]);
2588 }
2589
2590 #[rstest]
2591 #[case::sealed(false)]
2592 #[case::partial(true)]
2593 fn restart_discovers_crashed_files_and_deduplicates_kept_promotions(#[case] partial: bool) {
2594 let directory = TempDir::new().unwrap();
2595 let staging = directory.path().join("backtest/run-restart");
2596 let quote = sample_quote();
2597 let mut crashed = FeatherWriter::new(
2598 staging.clone(),
2599 WriterClock::Test(Arc::new(AtomicU64::new(0))),
2600 RotationConfig::NoRotation,
2601 None,
2602 None,
2603 );
2604 crashed.write(quote).unwrap();
2605 crashed.close().unwrap();
2606 let sealed = staging.join("quotes/quotes_0.feather");
2607
2608 if partial {
2609 fs::rename(&sealed, sealed.with_extension(FEATHER_PARTIAL_EXTENSION)).unwrap();
2610 }
2611
2612 drop(crashed);
2613 let config =
2614 WriterConnectConfig::new(staging.to_string_lossy(), Some(local_catalog(&directory)));
2615 let clock = WriterClock::Test(Arc::new(AtomicU64::new(0)));
2616 let mut writer = ParquetWriter::new(&config, clock.clone()).unwrap();
2617 let work = writer.prepare_promotion().unwrap().unwrap();
2618 assert_eq!(
2619 work.files(),
2620 &["backtest/run-restart/quotes/quotes_0.feather".to_string()]
2621 );
2622 assert_eq!(writer.finalize(work.execute()).unwrap().len(), 1);
2623 assert!(writer.prepare_promotion().unwrap().is_none());
2624 drop(writer);
2625
2626 let mut restarted = ParquetWriter::new(&config, clock).unwrap();
2627 let work = restarted.prepare_promotion().unwrap().unwrap();
2628 assert_eq!(work.files().len(), 1);
2629 assert!(restarted.finalize(work.execute()).unwrap().is_empty());
2630 assert!(restarted.prepare_promotion().unwrap().is_none());
2631 assert_scheduled_count(&restarted.core.promotion_driver.schedule(), 0);
2632 assert!(sealed.exists());
2633
2634 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2635 let DataBatch::Quote(rows) = catalog
2636 .query_batch(&CatalogQuery::new(NautilusDataType::QuoteTick))
2637 .unwrap()
2638 else {
2639 panic!("expected quotes")
2640 };
2641 assert_eq!(rows.as_ref(), &[quote]);
2642 }
2643
2644 #[rstest]
2645 #[case::nested_base("/prefix/catalog/", "prefix/catalog/")]
2646 #[case::root_base("", "")]
2647 fn promotion_paths_live_under_the_catalog_base_path(
2648 #[case] base_path: &str,
2649 #[case] expected_prefix: &str,
2650 ) {
2651 let directory = TempDir::new().unwrap();
2652 let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2653 catalog.base_path = base_path.to_string();
2654
2655 let backend = ParquetPromotionBackend {
2656 catalog,
2657 legacy_manifest_missing: Arc::default(),
2658 };
2659
2660 assert_eq!(
2661 backend.manifest_path(),
2662 ObjectPath::from(format!("{expected_prefix}_nautilus_promotions.json")),
2663 );
2664 assert_eq!(
2665 backend.marker_path("replay-id"),
2666 ObjectPath::from(format!(
2667 "{expected_prefix}_nautilus_promotions/\
2668 cd3112001080bc2d11985ffe2b8b90b324d336d82c17ddb66127d3a05f08c69c.json"
2669 )),
2670 );
2671 }
2672
2673 #[rstest]
2674 fn feather_replay_identity_is_stable_and_ignores_identifier_order() {
2675 let identity = |identifiers: Option<&[String]>| {
2676 feather_replay_identity(
2677 "file:///catalog/backtest/run-1",
2678 "quotes/AUDUSD.SIM/part-0.feather",
2679 "content-hash",
2680 identifiers,
2681 )
2682 };
2683
2684 let unordered = ["B".to_string(), "A".to_string(), "A".to_string()];
2685 let ordered = ["A".to_string(), "B".to_string()];
2686
2687 let expected =
2688 "nautilus-feather:10a9435c28f7536f26653c3fc808571be89bfe769e06c7307cb4743e273a23fd";
2689
2690 assert_eq!(identity(Some(&unordered)), expected);
2691 assert_eq!(identity(Some(&ordered)), expected);
2692 assert_ne!(identity(None), expected);
2693 }
2694}