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