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