Skip to main content

nautilus_persistence/backend/parquet/
writer.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautilustrader.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8// -------------------------------------------------------------------------------------------------
9
10//! Feather-staged promoting writer for the Parquet catalog.
11
12use 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        // A Parquet catalog opens only an existing local directory, so the writer creates the one
108        // it promotes into, then opens it now rather than failing at the first promotion
109        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        // A writer that exited before sealing leaves its flushed stream as a partial file
670        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    // Opening an S3 store needs no network, and a run with no data promotes nothing, so the
934    // writer records the run as completed when dropped
935    #[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(&quote).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        // Catalog identifier directories are urisafe, so a scoped query pins that spelling and
2295        // proves the converted records landed under their own identifier rather than together.
2296        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    // Earlier per-instrument writers staged files in identifier directories
2389    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}