Skip to main content

nautilus_persistence/backend/parquet/
feather_session.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.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//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Feather-file session reading and stream-to-parquet conversion.
17//!
18//! Methods for reading per-run feather files written by the live/backtest writers and
19//! converting them to consolidated parquet for catalog ingest.
20
21#![expect(
22    clippy::unused_self,
23    reason = "session registration keeps backend-specific ordering logic together"
24)]
25
26use std::{collections::HashMap, fs, io, sync::Arc};
27
28use ahash::AHashMap;
29use datafusion::arrow::{
30    array::{
31        Array, ArrayRef, FixedSizeListArray, GenericListArray, LargeListArray, ListArray,
32        OffsetSizeTrait, StructArray, UInt64Array,
33    },
34    compute::{SortColumn, SortOptions, concat_batches, lexsort_to_indices, take_record_batch},
35    datatypes::{DataType, Field, Schema},
36    record_batch::RecordBatch,
37};
38use futures::StreamExt;
39use indexmap::IndexMap;
40use nautilus_common::enums::Environment;
41use nautilus_core::UnixNanos;
42#[cfg(feature = "defi")]
43use nautilus_model::data::NautilusRecordType;
44use nautilus_model::{
45    data::{
46        Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus,
47        MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
48        TradeTick, close::InstrumentClose, to_variant,
49    },
50    enums::BookAction,
51};
52use nautilus_serialization::arrow::{
53    DecodeDataFromRecordBatch, DecodeTypedFromRecordBatch, KEY_TYPE_NAME, U64ColumnRef,
54    extract_column_by_name, extract_column_string, extract_decimal_column,
55    record_batch_without_identifier_column,
56};
57use object_store::path::Path as ObjectPath;
58
59use crate::{
60    backend::parquet::{
61        catalog::ParquetDataCatalog,
62        intervals::are_intervals_disjoint,
63        io::read_parquet_from_object_store,
64        paths::{
65            catalog_filename, make_object_store_path, query_intersects_filename,
66            urisafe_instrument_id,
67        },
68    },
69    catalog::types::{
70        CatalogDataType, instrument_path_prefix, parquet_data_path_prefix, record_path_prefix,
71    },
72    common::{
73        conversion::FeatherConversionSummary,
74        custom::decode_custom_batches_to_data,
75        datafusion::{filter_record_batch_by_identifier, identifiers_from_record_batches},
76        paths::{
77            catalog_data_type_from_session_feather_path, environment_directory,
78            identifier_from_session_feather_path,
79        },
80    },
81    writer::{
82        feather::recover_partial_feather_files,
83        materializer::{
84            StreamConversionOptions, apply_stream_conversion_transform,
85            coalesce_stream_conversion_batches, read_feather_record_batches,
86            restore_staged_record_batches,
87        },
88        run::FeatherSessionSource,
89    },
90};
91
92const PROMOTION_ROW_INDEX: &str = "nautilus_promotion_row_index";
93
94impl ParquetDataCatalog {
95    pub(crate) fn promote_feather_file(
96        &self,
97        source: &FeatherSessionSource,
98        feather_path: &str,
99        batches: Vec<RecordBatch>,
100        use_ts_event_for_ts_init: bool,
101        replay_identity: &str,
102    ) -> anyhow::Result<Option<FeatherConversionSummary>> {
103        let batches = Self::restore_staged_batches(batches)?;
104        if batches.is_empty() {
105            return Ok(None);
106        }
107
108        let data_type = catalog_data_type_from_session_feather_path(
109            feather_path,
110            source.environment,
111            &source.instance_id,
112        )?;
113        Self::ensure_stream_data_type(&data_type)?;
114
115        let identifier = Self::identifier_from_batch_or_path(
116            &batches[0],
117            feather_path,
118            source.environment,
119            &source.instance_id,
120        )
121        .filter(|identifier| {
122            batches.iter().all(|batch| {
123                Self::identifier_from_batch_or_path(
124                    batch,
125                    feather_path,
126                    source.environment,
127                    &source.instance_id,
128                )
129                .as_ref()
130                    == Some(identifier)
131            })
132        });
133
134        self.convert_feather_batches_to_parquet(
135            source.environment,
136            &source.instance_id,
137            &data_type,
138            feather_path,
139            &batches,
140            use_ts_event_for_ts_init,
141            Some(replay_identity),
142        )?;
143        Ok(Some(FeatherConversionSummary {
144            data_type,
145            identifier,
146            feather_path: feather_path.to_string(),
147            native_version: None,
148            unmatched_identifiers: None,
149        }))
150    }
151
152    /// Reads data from a live run instance.
153    ///
154    /// This method reads all data associated with a specific live run instance
155    /// from feather files stored in the catalog.
156    ///
157    /// # Parameters
158    ///
159    /// - `instance_id`: The ID of the live run instance to read.
160    ///
161    /// # Returns
162    ///
163    /// Returns a vector of `Data` objects from the live run, sorted by timestamp, or an empty
164    /// vector if the run does not exist. Returns an error if the operation fails.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if:
169    /// - The instance ID is empty, is `.` or `..`, or contains `/`, `\`, `:`, or a NUL character.
170    /// - The `live` run root does not resolve strictly inside the catalog.
171    /// - The run directory does not resolve strictly inside the `live` run root.
172    /// - Feather file reading fails.
173    /// - Data deserialization fails.
174    ///
175    /// # Note
176    ///
177    /// This method reads through the run reader: it lists the run's data-type directories, reads
178    /// every Feather file through the Arrow IPC stream reader with staged batch restoration, decodes
179    /// quotes, trades, order book deltas and depths, bars, index and mark prices, option Greeks,
180    /// funding rates, instrument status and closes, and custom data files into `Data` values, skips
181    /// unknown data types, and sorts the result by `ts_init`.
182    ///
183    /// # Examples
184    ///
185    /// ```rust,no_run
186    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
187    ///
188    /// let mut catalog = ParquetDataCatalog::new(
189    ///     std::path::Path::new("/tmp/nautilus_data"),
190    ///     None,
191    ///     None,
192    ///     None,
193    ///     None,
194    /// );
195    ///
196    /// // Read data from a live run
197    /// let data = catalog.read_live_run("instance-123")?;
198    /// for item in data {
199    ///     println!("Data: {:?}", item);
200    /// }
201    /// # Ok::<(), anyhow::Error>(())
202    /// ```
203    pub fn read_live_run(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
204        self.read_run(Environment::Live, instance_id)
205    }
206
207    /// Reads data from a backtest run instance.
208    ///
209    /// This method reads all data associated with a specific backtest run instance
210    /// from feather files stored in the catalog.
211    ///
212    /// # Parameters
213    ///
214    /// - `instance_id`: The ID of the backtest run instance to read.
215    ///
216    /// # Returns
217    ///
218    /// Returns a vector of `Data` objects from the backtest run, sorted by timestamp, or an empty
219    /// vector if the run does not exist. Returns an error if the operation fails.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if:
224    /// - The instance ID is empty, is `.` or `..`, or contains `/`, `\`, `:`, or a NUL character.
225    /// - The `backtest` run root does not resolve strictly inside the catalog.
226    /// - The run directory does not resolve strictly inside the `backtest` run root.
227    /// - Feather file reading fails.
228    /// - Data deserialization fails.
229    ///
230    /// # Examples
231    ///
232    /// ```rust,no_run
233    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
234    ///
235    /// let mut catalog = ParquetDataCatalog::new(
236    ///     std::path::Path::new("/tmp/nautilus_data"),
237    ///     None,
238    ///     None,
239    ///     None,
240    ///     None,
241    /// );
242    ///
243    /// // Read data from a backtest run
244    /// let data = catalog.read_backtest("instance-123")?;
245    /// for item in data {
246    ///     println!("Data: {:?}", item);
247    /// }
248    /// # Ok::<(), anyhow::Error>(())
249    /// ```
250    pub fn read_backtest(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
251        self.read_run(Environment::Backtest, instance_id)
252    }
253
254    /// Reads the sealed Feather files of a run instance (backtest or live).
255    ///
256    /// Abandoned `.feather.partial` files are recovered first, and files a writer still holds
257    /// open are not read. Families that do not decode to `Data`, such as order events, are
258    /// skipped.
259    fn read_run(&self, environment: Environment, instance_id: &str) -> anyhow::Result<Vec<Data>> {
260        self.recover_partial_run_files(environment, instance_id)?;
261        let feather_files = self.list_feather_files(environment, instance_id, None, None)?;
262        let mut all_data: Vec<Data> = Vec::new();
263
264        for file_path in feather_files {
265            let data_type =
266                catalog_data_type_from_session_feather_path(&file_path, environment, instance_id)?;
267
268            // A file may hold several batches
269            let batches = self.read_feather_file(&file_path)?;
270
271            if batches.is_empty() {
272                continue;
273            }
274
275            if let Some(file_data) = self.decode_run_batches(&data_type, batches)? {
276                all_data.extend(file_data);
277            }
278        }
279
280        all_data.sort_by_key(HasTsInit::ts_init);
281
282        Ok(all_data)
283    }
284
285    // Returns `None` for stream families that do not decode to `Data`
286    fn decode_run_batches(
287        &self,
288        data_type: &CatalogDataType,
289        batches: Vec<RecordBatch>,
290    ) -> anyhow::Result<Option<Vec<Data>>> {
291        let data = match data_type {
292            CatalogDataType::Data(NautilusDataType::QuoteTick) => self
293                .convert_record_batches_to_data::<QuoteTick>(batches, false)?
294                .into_iter()
295                .map(Data::from)
296                .collect(),
297            CatalogDataType::Data(NautilusDataType::TradeTick) => self
298                .convert_record_batches_to_data::<TradeTick>(batches, false)?
299                .into_iter()
300                .map(Data::from)
301                .collect(),
302            CatalogDataType::Data(NautilusDataType::OrderBookDelta) => self
303                .convert_record_batches_to_data::<OrderBookDelta>(batches, false)?
304                .into_iter()
305                .map(Data::from)
306                .collect(),
307            CatalogDataType::Data(NautilusDataType::OrderBookDepth) => self
308                .convert_record_batches_to_data::<OrderBookDepth>(batches, false)?
309                .into_iter()
310                .map(Data::from)
311                .collect(),
312            CatalogDataType::Data(NautilusDataType::Bar) => self
313                .convert_record_batches_to_data::<Bar>(batches, false)?
314                .into_iter()
315                .map(Data::from)
316                .collect(),
317            CatalogDataType::Data(NautilusDataType::IndexPriceUpdate) => self
318                .convert_record_batches_to_data::<IndexPriceUpdate>(batches, false)?
319                .into_iter()
320                .map(Data::from)
321                .collect(),
322            CatalogDataType::Data(NautilusDataType::MarkPriceUpdate) => self
323                .convert_record_batches_to_data::<MarkPriceUpdate>(batches, false)?
324                .into_iter()
325                .map(Data::from)
326                .collect(),
327            CatalogDataType::Data(NautilusDataType::OptionGreeks) => self
328                .convert_record_batches_to_data::<OptionGreeks>(batches, false)?
329                .into_iter()
330                .map(Data::from)
331                .collect(),
332            CatalogDataType::Data(NautilusDataType::FundingRateUpdate) => self
333                .convert_record_batches_to_data::<FundingRateUpdate>(batches, false)?
334                .into_iter()
335                .map(Data::from)
336                .collect(),
337            CatalogDataType::Data(NautilusDataType::InstrumentStatus) => self
338                .convert_record_batches_to_data::<InstrumentStatus>(batches, false)?
339                .into_iter()
340                .map(Data::from)
341                .collect(),
342            CatalogDataType::Data(NautilusDataType::InstrumentClose) => self
343                .convert_record_batches_to_data::<InstrumentClose>(batches, false)?
344                .into_iter()
345                .map(Data::from)
346                .collect(),
347            CatalogDataType::Data(NautilusDataType::Custom { .. }) => {
348                decode_custom_batches_to_data(batches, false)?
349            }
350            _ => return Ok(None),
351        };
352
353        Ok(Some(data))
354    }
355
356    fn recover_partial_run_files(
357        &self,
358        environment: Environment,
359        instance_id: &str,
360    ) -> anyhow::Result<()> {
361        anyhow::ensure!(
362            !instance_id.is_empty()
363                && !matches!(instance_id, "." | "..")
364                && !instance_id.contains(['/', '\\', ':', '\0']),
365            "Invalid run instance ID {instance_id:?}: expected a single path component",
366        );
367
368        // Writers stage only to local directories, so other catalogs hold no partial files
369        if !self.original_uri.starts_with("file://") {
370            return Ok(());
371        }
372
373        let catalog_root = match fs::canonicalize(self.native_base_path_string()) {
374            Ok(path) => path,
375            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
376            Err(e) => return Err(e.into()),
377        };
378
379        let run_root = match fs::canonicalize(catalog_root.join(environment_directory(environment)))
380        {
381            Ok(path) => path,
382            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
383            Err(e) => return Err(e.into()),
384        };
385
386        anyhow::ensure!(
387            run_root.starts_with(&catalog_root) && run_root != catalog_root,
388            "Run root resolves outside the catalog: {}",
389            run_root.display(),
390        );
391
392        let directory = match fs::canonicalize(run_root.join(instance_id)) {
393            Ok(path) => path,
394            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
395            Err(e) => return Err(e.into()),
396        };
397
398        anyhow::ensure!(
399            directory.starts_with(&run_root) && directory != run_root,
400            "Run directory resolves outside the run root: {}",
401            directory.display(),
402        );
403
404        recover_partial_feather_files(&directory);
405        Ok(())
406    }
407
408    /// Lists the feather files of a run, for one data type or every type when `data_type` is
409    /// `None`.
410    fn list_feather_files(
411        &self,
412        environment: Environment,
413        instance_id: &str,
414        data_type: Option<&CatalogDataType>,
415        identifiers: Option<&[String]>,
416    ) -> anyhow::Result<Vec<String>> {
417        let base_dir = make_object_store_path(
418            &self.base_path,
419            [environment_directory(environment), instance_id],
420        );
421
422        let mut files = self.execute_async(|| async {
423            let prefix = ObjectPath::from(format!("{base_dir}/"));
424            let mut stream = self.object_store.list(Some(&prefix));
425            let mut feather_files = Vec::new();
426
427            while let Some(object) = stream.next().await {
428                let object = object?;
429                let path_str = object.location.to_string();
430
431                if !path_str.ends_with(".feather") {
432                    continue;
433                }
434
435                let Ok(path_data_type) = catalog_data_type_from_session_feather_path(
436                    &path_str,
437                    environment,
438                    instance_id,
439                ) else {
440                    continue;
441                };
442
443                if data_type.is_some_and(|data_type| path_data_type != *data_type) {
444                    continue;
445                }
446
447                let path_identifier =
448                    identifier_from_session_feather_path(&path_str, environment, instance_id);
449
450                if let (Some(identifiers), Some(path_identifier)) =
451                    (identifiers, path_identifier.as_deref())
452                    && !Self::stream_identifier_matches(path_identifier, identifiers)
453                {
454                    continue;
455                }
456
457                feather_files.push(path_str);
458            }
459
460            Ok::<Vec<String>, anyhow::Error>(feather_files)
461        })?;
462
463        files.sort();
464        Ok(files)
465    }
466
467    // Keeps the rows whose identifier matches; a type's file holds every identifier, so the file
468    // listing cannot select them by folder
469    fn filter_batches_by_identifiers(
470        batches: Vec<RecordBatch>,
471        file_path: &str,
472        environment: Environment,
473        instance_id: &str,
474        identifiers: Option<&[String]>,
475    ) -> anyhow::Result<Vec<RecordBatch>> {
476        let Some(identifiers) = identifiers else {
477            return Ok(batches);
478        };
479
480        let path_identifier =
481            identifier_from_session_feather_path(file_path, environment, instance_id);
482
483        batches
484            .iter()
485            .filter_map(|batch| {
486                filter_record_batch_by_identifier(batch, path_identifier.as_deref(), |identifier| {
487                    identifier.is_none_or(|identifier| {
488                        Self::stream_identifier_matches(identifier, identifiers)
489                    })
490                })
491                .transpose()
492            })
493            .collect()
494    }
495
496    fn stream_identifier_matches(candidate: &str, identifiers: &[String]) -> bool {
497        identifiers.iter().any(|id| {
498            let safe_id = urisafe_instrument_id(id);
499            candidate.contains(id) || candidate.contains(&safe_id)
500        })
501    }
502
503    /// Reads a feather file and returns all `RecordBatches`.
504    fn read_feather_file(&self, file_path: &str) -> anyhow::Result<Vec<RecordBatch>> {
505        let path = self.to_object_path_parsed(file_path)?;
506
507        let batches = self.execute_async(|| async {
508            read_feather_record_batches(self.object_store.clone(), &path).await
509        })?;
510
511        Self::restore_staged_batches(batches)
512    }
513
514    fn restore_staged_batches(batches: Vec<RecordBatch>) -> anyhow::Result<Vec<RecordBatch>> {
515        let mut restored = Vec::new();
516        for batch in batches {
517            restored.extend(restore_staged_record_batches(batch)?);
518        }
519
520        Ok(restored)
521    }
522
523    /// Converts `RecordBatches` to Data objects, optionally replacing `ts_init` with `ts_event`.
524    fn convert_record_batches_to_data<T>(
525        &self,
526        batches: Vec<RecordBatch>,
527        use_ts_event_for_ts_init: bool,
528    ) -> anyhow::Result<Vec<T>>
529    where
530        T: DecodeDataFromRecordBatch + TryFrom<Data>,
531    {
532        let mut all_data = Vec::new();
533
534        for batch in batches {
535            let batch = apply_stream_conversion_transform(
536                &batch,
537                StreamConversionOptions {
538                    use_ts_event_for_ts_init,
539                    convert_bar_type_to_external: false,
540                },
541            )?;
542
543            let metadata = batch.schema().metadata().clone();
544
545            let data_vec = T::decode_data_batch(&metadata, batch)
546                .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
547
548            all_data.extend(data_vec);
549        }
550
551        Ok(to_variant::<T>(all_data))
552    }
553
554    /// Converts `RecordBatches` directly to strongly typed values.
555    pub(crate) fn convert_record_batches_to_typed<T>(
556        &self,
557        batches: Vec<RecordBatch>,
558    ) -> anyhow::Result<Vec<T>>
559    where
560        T: DecodeTypedFromRecordBatch,
561    {
562        let mut all_data = Vec::new();
563
564        for batch in batches {
565            let metadata = batch.schema().metadata().clone();
566            let decoded = T::decode_typed_batch(&metadata, batch)
567                .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
568            all_data.extend(decoded);
569        }
570
571        Ok(all_data)
572    }
573
574    /// Converts stream data from feather files to parquet files.
575    ///
576    /// This method reads data from feather files generated during a backtest or live run
577    /// and writes it to the catalog in parquet format. It's useful for converting temporary
578    /// stream data into a more permanent and queryable format.
579    ///
580    /// # Parameters
581    ///
582    /// - `instance_id`: The ID of the backtest or live run instance.
583    /// - `data_type`: The data or record type to convert, with the registered type name verbatim
584    ///   for custom data.
585    /// - `environment`: The environment of the run, which names the folder holding its feather files.
586    /// - `identifiers`: Optional list of identifiers to filter by (instrument IDs or bar types).
587    /// - `use_ts_event_for_ts_init`: If true, replaces the `ts_init` column with `ts_event` column values before deserializing.
588    ///
589    /// # Returns
590    ///
591    /// Returns `Ok(())` on success, or an error if the operation fails.
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if:
596    /// - `data_type` is an instrument class selector, which has no staged stream name.
597    /// - `data_type` is a family streams do not support.
598    /// - The instance ID is empty, is `.` or `..`, or contains `/`, `\`, `:`, or a NUL character.
599    /// - The environment's run root does not resolve strictly inside the catalog.
600    /// - The run directory does not resolve strictly inside the environment's run root.
601    /// - Feather file listing fails.
602    /// - Feather file reading fails.
603    /// - Writing to parquet fails.
604    ///
605    /// # Note
606    ///
607    /// This method converts directly between Arrow IPC stream batches and Parquet batches without
608    /// materializing Nautilus data objects. An instance with no staged files for the family
609    /// converts nothing and returns success. It requires:
610    /// - Recovering abandoned `.feather.partial` files of a local run
611    /// - Listing feather files in the environment's run folder
612    /// - Reading feather files (Arrow IPC stream reading)
613    /// - Applying table-only stream conversion transforms
614    /// - Writing Arrow batches to the catalog
615    ///
616    /// # Examples
617    ///
618    /// ```rust,no_run
619    /// use nautilus_common::enums::Environment;
620    /// use nautilus_model::data::NautilusDataType;
621    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
622    ///
623    /// let mut catalog = ParquetDataCatalog::new(
624    ///     std::path::Path::new("/tmp/nautilus_data"),
625    ///     None,
626    ///     None,
627    ///     None,
628    ///     None,
629    /// );
630    ///
631    /// // Convert backtest stream data to parquet
632    /// catalog.convert_stream_to_data(
633    ///     "instance-123",
634    ///     &NautilusDataType::QuoteTick.into(),
635    ///     Environment::Backtest,
636    ///     None,
637    ///     false,
638    /// )?;
639    /// # Ok::<(), anyhow::Error>(())
640    /// ```
641    pub fn convert_stream_to_data(
642        &mut self,
643        instance_id: &str,
644        data_type: &CatalogDataType,
645        environment: Environment,
646        identifiers: Option<&[String]>,
647        use_ts_event_for_ts_init: bool,
648    ) -> anyhow::Result<()> {
649        Self::ensure_stream_data_type(data_type)?;
650        self.recover_partial_run_files(environment, instance_id)?;
651        let feather_files =
652            self.list_feather_files(environment, instance_id, Some(data_type), identifiers)?;
653
654        // Each file is planned before it is written; a file holds every identifier of its type,
655        // and each restored batch carries its own identifier
656        for file_path in feather_files {
657            let batches = Self::filter_batches_by_identifiers(
658                self.read_feather_file(&file_path)?,
659                &file_path,
660                environment,
661                instance_id,
662                identifiers,
663            )?;
664
665            if batches.is_empty() {
666                continue;
667            }
668
669            self.convert_feather_batches_to_parquet(
670                environment,
671                instance_id,
672                data_type,
673                &file_path,
674                &batches,
675                use_ts_event_for_ts_init,
676                None,
677            )?;
678        }
679
680        Ok(())
681    }
682
683    #[expect(
684        clippy::too_many_arguments,
685        reason = "the arguments describe one Feather source and its catalog destination"
686    )]
687    fn convert_feather_batches_to_parquet(
688        &self,
689        environment: Environment,
690        instance_id: &str,
691        data_type: &CatalogDataType,
692        feather_path: &str,
693        batches: &[RecordBatch],
694        use_ts_event_for_ts_init: bool,
695        replay_identity: Option<&str>,
696    ) -> anyhow::Result<()> {
697        // A type's file holds every identifier, and custom data metadata does not name it, so
698        // rows are grouped by identifier as well as by schema
699        let mut groups: IndexMap<(Arc<Schema>, Option<String>), Vec<RecordBatch>> = IndexMap::new();
700        let mut row_offset = 0;
701
702        let mut rows = Vec::new();
703
704        for batch in batches {
705            anyhow::ensure!(
706                batch.column_by_name(PROMOTION_ROW_INDEX).is_none(),
707                "Feather source uses reserved column {PROMOTION_ROW_INDEX}"
708            );
709            let row_end = row_offset + batch.num_rows() as u64;
710            let mut fields = batch.schema().fields().to_vec();
711            fields.push(Arc::new(Field::new(
712                PROMOTION_ROW_INDEX,
713                DataType::UInt64,
714                false,
715            )));
716            let mut columns = batch.columns().to_vec();
717            columns.push(Arc::new(UInt64Array::from_iter_values(row_offset..row_end)));
718            let batch = RecordBatch::try_new(
719                Arc::new(Schema::new_with_metadata(
720                    fields,
721                    batch.schema().metadata().clone(),
722                )),
723                columns,
724            )?;
725            row_offset = row_end;
726            rows.extend(split_record_batch_by_identifier(&batch)?);
727        }
728
729        if *data_type == CatalogDataType::Data(NautilusDataType::OrderBookDelta) {
730            normalize_clear_precision(&mut rows)?;
731        }
732
733        for (identifier, batch) in rows {
734            groups
735                .entry((batch.schema(), identifier))
736                .or_default()
737                .push(batch);
738        }
739
740        let mut planned = Vec::new();
741
742        // Number each identifier's groups separately, so selecting identifiers for a conversion
743        // does not change the output identity of the groups it keeps.
744        let mut ordinals: AHashMap<Option<String>, usize> = AHashMap::new();
745
746        for ((_, identifier), group) in groups {
747            let ordinal = ordinals.entry(identifier.clone()).or_default();
748            let group_identity = format!(
749                "{}/{}/{ordinal}",
750                replay_identity.unwrap_or(feather_path),
751                identifier.as_deref().unwrap_or_default(),
752            );
753            *ordinal += 1;
754
755            if let Some(plan) = self.plan_catalog_write(
756                environment,
757                instance_id,
758                data_type,
759                feather_path,
760                &group,
761                use_ts_event_for_ts_init,
762                group_identity,
763            )? {
764                planned.push(plan);
765            }
766        }
767
768        let mut by_directory: IndexMap<String, Vec<PlannedCatalogWrite>> = IndexMap::new();
769
770        for plan in planned {
771            by_directory
772                .entry(plan.directory.clone())
773                .or_default()
774                .push(plan);
775        }
776
777        let mut ready = Vec::new();
778        for (directory, plans) in by_directory {
779            ready.extend(self.ready_directory_plans(data_type, &directory, plans)?);
780        }
781
782        for mut plan in ready {
783            let row_index = plan.batch.schema().index_of(PROMOTION_ROW_INDEX)?;
784            plan.batch.remove_column(row_index);
785            self.write_parquet_file_checked(
786                &plan.directory,
787                UnixNanos::from(plan.start_ts),
788                UnixNanos::from(plan.end_ts),
789                std::slice::from_ref(&plan.batch),
790                false,
791                "File",
792                None,
793                Some(&plan.group_identity),
794            )?;
795        }
796
797        Ok(())
798    }
799
800    #[expect(
801        clippy::too_many_arguments,
802        reason = "the arguments describe one restored schema group and its catalog destination"
803    )]
804    fn plan_catalog_write(
805        &self,
806        environment: Environment,
807        instance_id: &str,
808        data_type: &CatalogDataType,
809        feather_path: &str,
810        group: &[RecordBatch],
811        use_ts_event_for_ts_init: bool,
812        group_identity: String,
813    ) -> anyhow::Result<Option<PlannedCatalogWrite>> {
814        let Some(batch) = Self::apply_stream_conversion_transforms(group, use_ts_event_for_ts_init)
815            .map_err(|e| {
816                anyhow::anyhow!(
817                    "Failed to apply stream conversion transforms for {feather_path}: {e}"
818                )
819            })?
820        else {
821            return Ok(None);
822        };
823
824        let (start_ts, end_ts) = Self::ts_init_range(&batch).map_err(|e| {
825            anyhow::anyhow!("Failed to determine ts_init range for {feather_path}: {e}")
826        })?;
827
828        let identifier =
829            Self::identifier_from_batch_or_path(&batch, feather_path, environment, instance_id);
830
831        let identifier = identifier.as_deref();
832
833        // Like direct catalog writes, promoted files carry the identifier in their directory and
834        // metadata
835        let batch = record_batch_without_identifier_column(batch)?;
836
837        let directory = match data_type {
838            CatalogDataType::Data(NautilusDataType::Instrument) => {
839                let class = batch
840                    .schema()
841                    .metadata()
842                    .get(KEY_TYPE_NAME)
843                    .ok_or_else(|| anyhow::anyhow!("Staged instrument has no type_name metadata"))?
844                    .parse()?;
845                self.make_path(instrument_path_prefix(&class), identifier)?
846            }
847            CatalogDataType::Data(NautilusDataType::Custom { type_name }) => {
848                self.make_path_custom_data(type_name, identifier)?
849            }
850            CatalogDataType::Data(data_type) => {
851                self.make_path(&parquet_data_path_prefix(data_type), identifier)?
852            }
853            CatalogDataType::Record(record_type) => {
854                self.make_path(&record_path_prefix(record_type), identifier)?
855            }
856            CatalogDataType::Instrument(class) => {
857                self.make_path(instrument_path_prefix(class), identifier)?
858            }
859        };
860
861        let batch = Self::with_catalog_identifier_metadata(batch, data_type, identifier)?;
862
863        Ok(Some(PlannedCatalogWrite {
864            directory,
865            start_ts,
866            end_ts,
867            batch,
868            group_identity,
869        }))
870    }
871
872    fn ready_directory_plans(
873        &self,
874        data_type: &CatalogDataType,
875        directory: &str,
876        plans: Vec<PlannedCatalogWrite>,
877    ) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
878        let plans = coalesce_overlapping_plans(plans)?;
879        let mut remaining = Vec::new();
880
881        for plan in plans {
882            let filename = catalog_filename(
883                UnixNanos::from(plan.start_ts),
884                UnixNanos::from(plan.end_ts),
885                Some(&plan.group_identity),
886            );
887            let path = format!("{directory}/{filename}");
888            if !self.file_exists(&path)? {
889                remaining.push(plan);
890            }
891        }
892
893        if remaining.is_empty() {
894            return Ok(remaining);
895        }
896
897        let existing = self.get_directory_intervals(directory)?;
898
899        if *data_type == CatalogDataType::Data(NautilusDataType::Bar) {
900            let mut filtered = Vec::new();
901
902            for plan in remaining {
903                if !existing
904                    .iter()
905                    .any(|&(start, end)| plan.start_ts <= end && start <= plan.end_ts)
906                {
907                    filtered.push(plan);
908                } else if let Some(plan) = self.remove_replayed_bar_rows(directory, plan)? {
909                    filtered.push(plan);
910                }
911            }
912
913            remaining = filtered;
914        }
915
916        let mut intervals = existing.clone();
917        intervals.extend(remaining.iter().map(|plan| (plan.start_ts, plan.end_ts)));
918
919        if !are_intervals_disjoint(&intervals) {
920            anyhow::bail!(
921                "Writing promoted groups for {directory} would create non-disjoint intervals. \
922                 Existing intervals: {existing:?}"
923            );
924        }
925
926        Ok(remaining)
927    }
928
929    fn remove_replayed_bar_rows(
930        &self,
931        directory: &str,
932        mut plan: PlannedCatalogWrite,
933    ) -> anyhow::Result<Option<PlannedCatalogWrite>> {
934        let row_index = plan.batch.schema().index_of(PROMOTION_ROW_INDEX)?;
935        let columns = (0..plan.batch.num_columns())
936            .filter(|&index| index != row_index)
937            .collect::<Vec<_>>();
938        let incoming = plan.batch.project(&columns)?;
939        let mut persisted = Vec::new();
940        let mut rows_by_timestamp: AHashMap<u64, Vec<(usize, usize)>> = AHashMap::new();
941
942        for file in self.list_parquet_files(directory)? {
943            if !query_intersects_filename(&file, Some(plan.start_ts), Some(plan.end_ts)) {
944                continue;
945            }
946
947            let path = self.to_object_path_parsed(&file)?;
948            let (batches, schema) = self.execute_async(|| {
949                read_parquet_from_object_store(self.object_store.clone(), &path)
950            })?;
951
952            for batch in batches {
953                let batch = RecordBatch::try_new(schema.clone(), batch.columns().to_vec())?;
954                let timestamps = Self::ts_init_array(&batch)?;
955                for row in 0..batch.num_rows() {
956                    let timestamp = timestamps
957                        .value(row)
958                        .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
959                    rows_by_timestamp
960                        .entry(timestamp)
961                        .or_default()
962                        .push((persisted.len(), row));
963                }
964
965                persisted.push(batch);
966            }
967        }
968
969        let timestamps = Self::ts_init_array(&incoming)?;
970        let mut keep = Vec::new();
971
972        for row in 0..incoming.num_rows() {
973            let timestamp = timestamps
974                .value(row)
975                .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
976
977            let replayed = rows_by_timestamp.get(&timestamp).is_some_and(|matches| {
978                let incoming_row = incoming.slice(row, 1);
979                matches
980                    .iter()
981                    .all(|&(batch, index)| persisted[batch].slice(index, 1) == incoming_row)
982            });
983
984            if !replayed {
985                keep.push(row as u64);
986            }
987        }
988
989        if keep.is_empty() {
990            return Ok(None);
991        }
992
993        plan.batch = take_record_batch(&plan.batch, &UInt64Array::from(keep))?;
994        (plan.start_ts, plan.end_ts) = Self::ts_init_range(&plan.batch)?;
995        Ok(Some(plan))
996    }
997
998    fn with_catalog_identifier_metadata(
999        batch: RecordBatch,
1000        data_type: &CatalogDataType,
1001        identifier: Option<&str>,
1002    ) -> anyhow::Result<RecordBatch> {
1003        let Some(identifier) = identifier else {
1004            return Ok(batch);
1005        };
1006
1007        let metadata_key = if *data_type == CatalogDataType::Data(NautilusDataType::Bar) {
1008            "bar_type"
1009        } else {
1010            "instrument_id"
1011        };
1012
1013        if batch.schema().metadata().contains_key(metadata_key) {
1014            return Ok(batch);
1015        }
1016
1017        let mut metadata = batch.schema().metadata().clone();
1018        metadata.insert(metadata_key.to_string(), identifier.to_string());
1019
1020        let schema = Arc::new(Schema::new_with_metadata(
1021            batch.schema().fields().clone(),
1022            metadata,
1023        ));
1024        Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
1025    }
1026
1027    fn apply_stream_conversion_transforms(
1028        batches: &[RecordBatch],
1029        use_ts_event_for_ts_init: bool,
1030    ) -> anyhow::Result<Option<RecordBatch>> {
1031        coalesce_stream_conversion_batches(
1032            batches,
1033            StreamConversionOptions {
1034                use_ts_event_for_ts_init,
1035                convert_bar_type_to_external: true,
1036            },
1037        )
1038    }
1039
1040    fn ts_init_range(batch: &RecordBatch) -> anyhow::Result<(u64, u64)> {
1041        let ts_init = Self::ts_init_array(batch)?;
1042        if ts_init.is_empty() {
1043            anyhow::bail!("Cannot convert empty stream batch to parquet");
1044        }
1045
1046        if (0..ts_init.len()).any(|row| ts_init.is_null(row)) {
1047            anyhow::bail!("ts_init column contains null values");
1048        }
1049
1050        let start = ts_init
1051            .value(0)
1052            .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
1053        let end = ts_init
1054            .value(ts_init.len() - 1)
1055            .ok_or_else(|| anyhow::anyhow!("ts_init value cannot be negative"))?;
1056        Ok((start, end))
1057    }
1058
1059    fn ts_init_array(batch: &RecordBatch) -> anyhow::Result<U64ColumnRef<'_>> {
1060        let ts_init_idx = batch
1061            .schema()
1062            .index_of("ts_init")
1063            .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
1064        U64ColumnRef::try_from_array(batch.column(ts_init_idx).as_ref())
1065            .ok_or_else(|| anyhow::anyhow!("ts_init column has an unsupported type"))
1066    }
1067
1068    fn identifier_from_batch_or_path(
1069        batch: &RecordBatch,
1070        feather_path: &str,
1071        environment: Environment,
1072        instance_id: &str,
1073    ) -> Option<String> {
1074        let metadata = batch.schema().metadata().clone();
1075        if let Some(bar_type) = metadata.get("bar_type") {
1076            return Some(bar_type.clone());
1077        }
1078
1079        if let Some(instrument_id) = metadata.get("instrument_id") {
1080            return Some(instrument_id.clone());
1081        }
1082
1083        if let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch))
1084            && identifiers.len() == 1
1085        {
1086            return identifiers.into_iter().next();
1087        }
1088
1089        identifier_from_session_feather_path(feather_path, environment, instance_id)
1090    }
1091
1092    fn ensure_stream_data_type(data_type: &CatalogDataType) -> anyhow::Result<()> {
1093        match data_type {
1094            // Streams stage instruments under the aggregate family, with the class carried per
1095            // batch, so a class selector names no staged file
1096            CatalogDataType::Instrument(class) => anyhow::bail!(
1097                "Streams stage instruments under the aggregate family, not {class}; \
1098                 pass the Instrument data type"
1099            ),
1100            #[cfg(feature = "defi")]
1101            CatalogDataType::Data(NautilusDataType::Defi)
1102            | CatalogDataType::Record(NautilusRecordType::Defi) => {
1103                anyhow::bail!("Streams do not support {data_type}")
1104            }
1105            _ => Ok(()),
1106        }
1107    }
1108}
1109
1110struct PlannedCatalogWrite {
1111    directory: String,
1112    start_ts: u64,
1113    end_ts: u64,
1114    batch: RecordBatch,
1115    group_identity: String,
1116}
1117
1118// Splits `batch` into one batch per identifier, with rows that have no identifier as their own
1119// group; a batch without an identifier column is returned whole
1120fn split_record_batch_by_identifier(
1121    batch: &RecordBatch,
1122) -> anyhow::Result<Vec<(Option<String>, RecordBatch)>> {
1123    let Ok(identifiers) = identifiers_from_record_batches(std::slice::from_ref(batch)) else {
1124        return Ok(vec![(None, batch.clone())]);
1125    };
1126
1127    let mut split = Vec::with_capacity(identifiers.len() + 1);
1128    for identifier in identifiers.into_iter().map(Some).chain([None]) {
1129        if let Some(rows) =
1130            filter_record_batch_by_identifier(batch, None, |value| value == identifier.as_deref())?
1131        {
1132            split.push((identifier, rows));
1133        }
1134    }
1135
1136    Ok(split)
1137}
1138
1139fn normalize_clear_precision(batches: &mut [(Option<String>, RecordBatch)]) -> anyhow::Result<()> {
1140    let clear_only = batches
1141        .iter()
1142        .map(|(_, batch)| is_canonical_clear_batch(batch))
1143        .collect::<anyhow::Result<Vec<_>>>()?;
1144
1145    if !clear_only.iter().any(|clear| *clear) {
1146        return Ok(());
1147    }
1148
1149    let mut targets = AHashMap::new();
1150
1151    // The last populated schema provides the fallback for trailing CLEAR rows
1152    for ((identifier, batch), clear) in batches.iter().zip(&clear_only) {
1153        if !clear && batch.num_rows() != 0 {
1154            targets.insert(identifier.clone(), batch.schema());
1155        }
1156    }
1157
1158    // Prefer the following snapshot's schema before grouping, so CLEAR retains source order
1159    for ((identifier, batch), clear) in batches.iter_mut().zip(clear_only).rev() {
1160        if batch.num_rows() == 0 {
1161            continue;
1162        }
1163
1164        if !clear {
1165            targets.insert(identifier.clone(), batch.schema());
1166            continue;
1167        }
1168
1169        let Some(target) = targets.get(identifier) else {
1170            continue;
1171        };
1172
1173        let schema = batch.schema();
1174        if schema.fields() != target.fields()
1175            || metadata_without_precision(&schema) != metadata_without_precision(target)
1176        {
1177            continue;
1178        }
1179
1180        *batch = RecordBatch::try_new(target.clone(), batch.columns().to_vec())?;
1181    }
1182
1183    Ok(())
1184}
1185
1186fn is_canonical_clear_batch(batch: &RecordBatch) -> anyhow::Result<bool> {
1187    if precision_values(batch.schema().as_ref()) != (Some("0"), Some("0")) {
1188        return Ok(false);
1189    }
1190
1191    let action_index = batch.schema().index_of("action")?;
1192    let side_index = batch.schema().index_of("side")?;
1193    let action = extract_column_string(batch.columns(), "action", action_index)?;
1194    let order_side = extract_column_string(batch.columns(), "side", side_index)?;
1195    let price = extract_decimal_column(batch, "price")?;
1196    let size = extract_decimal_column(batch, "size")?;
1197    let order_id = extract_column_by_name::<UInt64Array>(batch, "order_id", DataType::UInt64)?;
1198
1199    Ok((0..batch.num_rows()).all(|row| {
1200        action.value_opt(row) == Some(BookAction::Clear.as_ref())
1201            && order_side.value_opt(row) == Some("NO_ORDER_SIDE")
1202            && (price.is_null(row) || price.value(row) == 0)
1203            && size.is_valid(row)
1204            && size.value(row) == 0
1205            && order_id.is_valid(row)
1206            && order_id.value(row) == 0
1207    }))
1208}
1209
1210fn coalesce_overlapping_plans(
1211    mut plans: Vec<PlannedCatalogWrite>,
1212) -> anyhow::Result<Vec<PlannedCatalogWrite>> {
1213    loop {
1214        let mut changed = false;
1215        let mut next = Vec::new();
1216
1217        while let Some(plan) = plans.pop() {
1218            if let Some(index) = next
1219                .iter()
1220                .position(|other| plan_intervals_overlap(other, &plan))
1221            {
1222                let other = next.swap_remove(index);
1223                next.push(unify_plans(other, plan)?);
1224                changed = true;
1225            } else {
1226                next.push(plan);
1227            }
1228        }
1229
1230        plans = next;
1231
1232        if !changed {
1233            break;
1234        }
1235    }
1236
1237    Ok(plans)
1238}
1239
1240fn plan_intervals_overlap(left: &PlannedCatalogWrite, right: &PlannedCatalogWrite) -> bool {
1241    left.start_ts <= right.end_ts && right.start_ts <= left.end_ts
1242}
1243
1244fn unify_plans(
1245    left: PlannedCatalogWrite,
1246    right: PlannedCatalogWrite,
1247) -> anyhow::Result<PlannedCatalogWrite> {
1248    let batch = unify_record_batches(left.batch, right.batch)?;
1249    let (start_ts, end_ts) = ParquetDataCatalog::ts_init_range(&batch)?;
1250
1251    Ok(PlannedCatalogWrite {
1252        directory: left.directory,
1253        start_ts,
1254        end_ts,
1255        batch,
1256        group_identity: left.group_identity,
1257    })
1258}
1259
1260fn unify_record_batches(left: RecordBatch, right: RecordBatch) -> anyhow::Result<RecordBatch> {
1261    if left.schema() == right.schema() {
1262        return concat_sorted(&left, &right);
1263    }
1264
1265    anyhow::ensure!(
1266        left.schema().fields() == right.schema().fields()
1267            && metadata_without_precision(left.schema().as_ref())
1268                == metadata_without_precision(right.schema().as_ref()),
1269        "overlapping promotion groups have incompatible schemas"
1270    );
1271
1272    let target = precision_target_schema(left.schema().as_ref(), right.schema().as_ref())?;
1273    let left = relabel_precision(left, &target)?;
1274    let right = relabel_precision(right, &target)?;
1275    concat_sorted(&left, &right)
1276}
1277
1278fn precision_target_schema(left: &Schema, right: &Schema) -> anyhow::Result<Schema> {
1279    if precision_values(left) == precision_values(right) {
1280        return Ok(left.clone());
1281    }
1282
1283    if is_precision_sentinel(left) && !is_precision_sentinel(right) {
1284        return Ok(right.clone());
1285    }
1286
1287    if is_precision_sentinel(right) && !is_precision_sentinel(left) {
1288        return Ok(left.clone());
1289    }
1290
1291    anyhow::bail!("overlapping promotion groups have incompatible precision metadata")
1292}
1293
1294fn relabel_precision(batch: RecordBatch, target: &Schema) -> anyhow::Result<RecordBatch> {
1295    if batch.schema().as_ref() == target {
1296        return Ok(batch);
1297    }
1298
1299    let precision_changes = precision_values(batch.schema().as_ref()) != precision_values(target);
1300    if precision_changes && batch_has_present_decimal(&batch) {
1301        anyhow::bail!(
1302            "cannot relabel precision metadata for a promotion group that contains decimal values"
1303        );
1304    }
1305
1306    Ok(RecordBatch::try_new(
1307        Arc::new(target.clone()),
1308        batch.columns().to_vec(),
1309    )?)
1310}
1311
1312fn concat_sorted(left: &RecordBatch, right: &RecordBatch) -> anyhow::Result<RecordBatch> {
1313    let schema = left.schema();
1314    let batch = concat_batches(&schema, [left, right])
1315        .map_err(|e| anyhow::anyhow!("Failed to concatenate promotion groups: {e}"))?;
1316    sort_by_ts_init(&batch)
1317}
1318
1319fn sort_by_ts_init(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1320    let ts_init = batch
1321        .schema()
1322        .index_of("ts_init")
1323        .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
1324    let original_row_index = batch
1325        .column_by_name(PROMOTION_ROW_INDEX)
1326        .cloned()
1327        .unwrap_or_else(|| Arc::new(UInt64Array::from_iter_values(0..batch.num_rows() as u64)));
1328
1329    let options = Some(SortOptions {
1330        descending: false,
1331        nulls_first: false,
1332    });
1333
1334    let indices = lexsort_to_indices(
1335        &[
1336            SortColumn {
1337                values: batch.column(ts_init).clone(),
1338                options,
1339            },
1340            SortColumn {
1341                values: original_row_index,
1342                options,
1343            },
1344        ],
1345        None,
1346    )
1347    .map_err(|e| anyhow::anyhow!("Failed to sort promotion group: {e}"))?;
1348
1349    take_record_batch(batch, &indices)
1350        .map_err(|e| anyhow::anyhow!("Failed to reorder promotion group: {e}"))
1351}
1352
1353fn metadata_without_precision(schema: &Schema) -> HashMap<String, String> {
1354    schema
1355        .metadata()
1356        .iter()
1357        .filter(|(key, _)| *key != "price_precision" && *key != "size_precision")
1358        .map(|(key, value)| (key.clone(), value.clone()))
1359        .collect()
1360}
1361
1362fn precision_values(schema: &Schema) -> (Option<&str>, Option<&str>) {
1363    (
1364        schema.metadata().get("price_precision").map(String::as_str),
1365        schema.metadata().get("size_precision").map(String::as_str),
1366    )
1367}
1368
1369fn is_precision_sentinel(schema: &Schema) -> bool {
1370    let (price, size) = precision_values(schema);
1371    (price.is_some() || size.is_some())
1372        && price.is_none_or(|value| value == "0")
1373        && size.is_none_or(|value| value == "0")
1374}
1375
1376fn batch_has_present_decimal(batch: &RecordBatch) -> bool {
1377    batch
1378        .columns()
1379        .iter()
1380        .any(|column| array_has_present_decimal(column.as_ref()))
1381}
1382
1383fn array_has_present_decimal(array: &dyn Array) -> bool {
1384    match array.data_type() {
1385        DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => {
1386            !array.is_empty() && array.null_count() < array.len()
1387        }
1388        // A sliced list keeps its whole child array, so check only the values its rows reference
1389        DataType::List(_) => array
1390            .as_any()
1391            .downcast_ref::<ListArray>()
1392            .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1393        DataType::LargeList(_) => array
1394            .as_any()
1395            .downcast_ref::<LargeListArray>()
1396            .is_none_or(|list| array_has_present_decimal(list_values(list).as_ref())),
1397        DataType::FixedSizeList(_, _) => array
1398            .as_any()
1399            .downcast_ref::<FixedSizeListArray>()
1400            .is_none_or(|list| array_has_present_decimal(list.values().as_ref())),
1401        DataType::Struct(_) => array
1402            .as_any()
1403            .downcast_ref::<StructArray>()
1404            .is_none_or(|values| {
1405                values
1406                    .columns()
1407                    .iter()
1408                    .any(|column| array_has_present_decimal(column.as_ref()))
1409            }),
1410        _ => false,
1411    }
1412}
1413
1414fn list_values<O: OffsetSizeTrait>(list: &GenericListArray<O>) -> ArrayRef {
1415    let offsets = list.value_offsets();
1416    let start = offsets[0].as_usize();
1417    let end = offsets[offsets.len() - 1].as_usize();
1418    list.values().slice(start, end - start)
1419}
1420
1421#[cfg(test)]
1422mod promotion_benchmarks;
1423
1424#[cfg(test)]
1425mod promotion_group_tests {
1426    use std::{collections::HashMap, sync::Arc};
1427
1428    use datafusion::arrow::{
1429        array::{Array, Decimal128Array, ListArray, StringArray, UInt64Array},
1430        buffer::OffsetBuffer,
1431        datatypes::{DataType, Field, Schema},
1432        record_batch::RecordBatch,
1433    };
1434    use nautilus_common::enums::Environment;
1435    use nautilus_model::{
1436        data::{BookOrder, NautilusDataType, OrderBookDelta},
1437        enums::{BookAction, OrderSide},
1438        identifiers::InstrumentId,
1439        types::{Price, Quantity},
1440    };
1441    use nautilus_serialization::arrow::{EncodeToRecordBatch, KEY_IDENTIFIER};
1442    use rstest::rstest;
1443    use tempfile::TempDir;
1444
1445    use super::{
1446        array_has_present_decimal, normalize_clear_precision, split_record_batch_by_identifier,
1447    };
1448    use crate::{
1449        backend::parquet::{catalog::ParquetDataCatalog, io::read_parquet_from_object_store},
1450        common::storage::create_storage_backend_from_path,
1451        writer::{
1452            feather::{NAUTILUS_ARROW_METADATA_ID_COLUMN, NAUTILUS_ARROW_METADATA_JSON_COLUMN},
1453            run::FeatherSessionSource,
1454        },
1455    };
1456
1457    fn precision_batch(precision: &str, timestamps: Vec<u64>, price: Option<i128>) -> RecordBatch {
1458        let mut metadata = HashMap::new();
1459        metadata.insert("instrument_id".to_string(), "ETH/USDT.BINANCE".to_string());
1460        metadata.insert("price_precision".to_string(), precision.to_string());
1461        metadata.insert("size_precision".to_string(), "0".to_string());
1462        let price = Decimal128Array::from(vec![price; timestamps.len()])
1463            .with_precision_and_scale(38, 16)
1464            .unwrap();
1465        RecordBatch::try_new(
1466            Arc::new(Schema::new_with_metadata(
1467                vec![
1468                    Field::new("ts_init", DataType::UInt64, false),
1469                    Field::new("price", price.data_type().clone(), true),
1470                ],
1471                metadata,
1472            )),
1473            vec![Arc::new(UInt64Array::from(timestamps)), Arc::new(price)],
1474        )
1475        .unwrap()
1476    }
1477
1478    #[rstest]
1479    #[case::empty_row(0, false)]
1480    #[case::populated_row(1, true)]
1481    fn sliced_list_checks_only_its_rows_for_decimals(#[case] row: usize, #[case] expected: bool) {
1482        let price = Decimal128Array::from(vec![Some(123_i128)])
1483            .with_precision_and_scale(38, 16)
1484            .unwrap();
1485        let field = Arc::new(Field::new("item", price.data_type().clone(), false));
1486
1487        let list = ListArray::new(
1488            field,
1489            OffsetBuffer::new(vec![0_i32, 0, 1].into()),
1490            Arc::new(price),
1491            None,
1492        );
1493
1494        assert_eq!(array_has_present_decimal(&list.slice(row, 1)), expected);
1495    }
1496
1497    #[rstest]
1498    #[case::one_identifier(
1499        vec![Some("A"), None],
1500        vec![(Some("A"), vec![1]), (None, vec![2])],
1501    )]
1502    #[case::two_identifiers(
1503        vec![Some("A"), None, Some("B"), None],
1504        vec![(Some("A"), vec![1]), (Some("B"), vec![3]), (None, vec![2, 4])],
1505    )]
1506    fn split_by_identifier_keeps_rows_without_identifier_as_their_own_group(
1507        #[case] identifiers: Vec<Option<&str>>,
1508        #[case] expected: Vec<(Option<&str>, Vec<u64>)>,
1509    ) {
1510        let values = (1..=identifiers.len() as u64).collect::<Vec<_>>();
1511        let batch = RecordBatch::try_new(
1512            Arc::new(Schema::new(vec![
1513                Field::new("value", DataType::UInt64, false),
1514                Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
1515            ])),
1516            vec![
1517                Arc::new(UInt64Array::from(values)),
1518                Arc::new(StringArray::from(identifiers)),
1519            ],
1520        )
1521        .unwrap();
1522
1523        let split = split_record_batch_by_identifier(&batch)
1524            .unwrap()
1525            .into_iter()
1526            .map(|(identifier, rows)| {
1527                let values = rows
1528                    .column(0)
1529                    .as_any()
1530                    .downcast_ref::<UInt64Array>()
1531                    .unwrap()
1532                    .values()
1533                    .to_vec();
1534                (identifier, values)
1535            })
1536            .collect::<Vec<_>>();
1537
1538        assert_eq!(
1539            split,
1540            expected
1541                .into_iter()
1542                .map(|(identifier, values)| (identifier.map(String::from), values))
1543                .collect::<Vec<_>>(),
1544        );
1545    }
1546
1547    #[rstest]
1548    fn clear_precision_keeps_instruments_separate() {
1549        let id_a = InstrumentId::from("AUD/USD.SIM");
1550        let id_b = InstrumentId::from("BTC/USD.SIM");
1551
1552        let add_a = OrderBookDelta::new(
1553            id_a,
1554            BookAction::Add,
1555            BookOrder::new(
1556                OrderSide::Buy,
1557                Price::from("1.23"),
1558                Quantity::from("4.500"),
1559                71,
1560            ),
1561            128,
1562            41,
1563            99.into(),
1564            100.into(),
1565        );
1566
1567        let add_b = OrderBookDelta::new(
1568            id_b,
1569            BookAction::Add,
1570            BookOrder::new(
1571                OrderSide::Sell,
1572                Price::from("12.3456"),
1573                Quantity::from("7.89000"),
1574                72,
1575            ),
1576            128,
1577            42,
1578            199.into(),
1579            200.into(),
1580        );
1581        let deltas = [
1582            OrderBookDelta::clear(id_a, 41, 99.into(), 100.into()),
1583            OrderBookDelta::clear(id_b, 42, 199.into(), 200.into()),
1584            add_a,
1585            add_b,
1586        ];
1587
1588        let mut batches = deltas
1589            .iter()
1590            .map(|delta| {
1591                (
1592                    Some(delta.instrument_id.to_string()),
1593                    OrderBookDelta::encode_batch(&delta.metadata(), &[*delta]).unwrap(),
1594                )
1595            })
1596            .collect::<Vec<_>>();
1597
1598        let original = batches.clone();
1599
1600        normalize_clear_precision(&mut batches).unwrap();
1601
1602        for ((identifier, batch), (original_identifier, original_batch)) in
1603            batches.iter().zip(&original)
1604        {
1605            let expected_schema = if *identifier == Some(id_a.to_string()) {
1606                original[2].1.schema()
1607            } else {
1608                original[3].1.schema()
1609            };
1610
1611            assert_eq!(identifier, original_identifier);
1612            assert_eq!(batch.schema(), expected_schema);
1613            assert_eq!(batch.columns(), original_batch.columns());
1614        }
1615    }
1616
1617    #[rstest]
1618    #[case::clear_only(false)]
1619    #[case::empty_following_batch(true)]
1620    fn clear_precision_without_following_orders(#[case] populated: bool) {
1621        let id = InstrumentId::from("AUD/USD.SIM");
1622        let clear = OrderBookDelta::clear(id, 41, 99.into(), 100.into());
1623        let clear_batch = OrderBookDelta::encode_batch(&clear.metadata(), &[clear]).unwrap();
1624
1625        let add = OrderBookDelta::new(
1626            id,
1627            BookAction::Add,
1628            BookOrder::new(
1629                OrderSide::Buy,
1630                Price::from("1.23"),
1631                Quantity::from("4.500"),
1632                71,
1633            ),
1634            128,
1635            40,
1636            49.into(),
1637            50.into(),
1638        );
1639        let add_batch = OrderBookDelta::encode_batch(&add.metadata(), &[add]).unwrap();
1640        let mut batches = Vec::new();
1641        if populated {
1642            batches.push((Some(id.to_string()), add_batch.clone()));
1643        }
1644
1645        batches.push((Some(id.to_string()), clear_batch.clone()));
1646        let clear_index = batches.len() - 1;
1647
1648        if populated {
1649            let mut metadata = add.metadata();
1650            metadata.insert("price_precision".to_string(), "4".to_string());
1651            let empty = RecordBatch::new_empty(Arc::new(
1652                add_batch.schema().as_ref().clone().with_metadata(metadata),
1653            ));
1654            batches.push((Some(id.to_string()), empty));
1655        }
1656
1657        normalize_clear_precision(&mut batches).unwrap();
1658
1659        let expected_schema = if populated {
1660            add_batch.schema()
1661        } else {
1662            clear_batch.schema()
1663        };
1664
1665        assert_eq!(batches[clear_index].1.schema(), expected_schema);
1666        assert_eq!(batches[clear_index].1.columns(), clear_batch.columns());
1667        assert_eq!(batches.len(), if populated { 3 } else { 1 });
1668    }
1669
1670    #[rstest]
1671    fn overlapping_empty_precision_group_is_promoted() {
1672        let temp = TempDir::new().unwrap();
1673        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1674        let batches = vec![
1675            precision_batch("0", vec![1, 3], None),
1676            precision_batch("2", vec![2], Some(20_000_000_000_000_000)),
1677        ];
1678
1679        catalog
1680            .convert_feather_batches_to_parquet(
1681                Environment::Backtest,
1682                "run-1",
1683                &NautilusDataType::QuoteTick.into(),
1684                "backtest/run-1/quotes_1.feather",
1685                &batches,
1686                false,
1687                Some("replay"),
1688            )
1689            .unwrap();
1690        catalog
1691            .convert_feather_batches_to_parquet(
1692                Environment::Backtest,
1693                "run-1",
1694                &NautilusDataType::QuoteTick.into(),
1695                "backtest/run-1/quotes_1.feather",
1696                &batches,
1697                false,
1698                Some("replay"),
1699            )
1700            .unwrap();
1701
1702        assert_eq!(
1703            catalog
1704                .get_intervals(
1705                    &NautilusDataType::QuoteTick.into(),
1706                    Some("ETH/USDT.BINANCE")
1707                )
1708                .unwrap(),
1709            vec![(1, 3)],
1710        );
1711    }
1712
1713    #[rstest]
1714    fn promotion_rejects_reserved_row_index() {
1715        let temp = TempDir::new().unwrap();
1716        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1717        let batch = precision_batch("2", vec![1], Some(1));
1718        let mut fields = batch.schema().fields().to_vec();
1719        fields.push(Arc::new(Field::new(
1720            super::PROMOTION_ROW_INDEX,
1721            DataType::UInt64,
1722            false,
1723        )));
1724        let mut columns = batch.columns().to_vec();
1725        columns.push(Arc::new(UInt64Array::from(vec![19])));
1726        let batch = RecordBatch::try_new(
1727            Arc::new(Schema::new_with_metadata(
1728                fields,
1729                batch.schema().metadata().clone(),
1730            )),
1731            columns,
1732        )
1733        .unwrap();
1734
1735        let error = catalog
1736            .convert_feather_batches_to_parquet(
1737                Environment::Backtest,
1738                "run-1",
1739                &NautilusDataType::QuoteTick.into(),
1740                "backtest/run-1/quotes_1.feather",
1741                &[batch],
1742                false,
1743                Some("replay"),
1744            )
1745            .unwrap_err();
1746
1747        assert_eq!(
1748            error.to_string(),
1749            "Feather source uses reserved column nautilus_promotion_row_index"
1750        );
1751        assert_eq!(
1752            catalog
1753                .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1754                .unwrap(),
1755            Vec::<String>::new()
1756        );
1757    }
1758
1759    #[rstest]
1760    fn overlapping_incompatible_groups_write_nothing() {
1761        let temp = TempDir::new().unwrap();
1762        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1763        let batches = vec![
1764            precision_batch("2", vec![1, 3], Some(1)),
1765            precision_batch("5", vec![2], Some(2)),
1766        ];
1767
1768        let error = catalog
1769            .convert_feather_batches_to_parquet(
1770                Environment::Backtest,
1771                "run-1",
1772                &NautilusDataType::QuoteTick.into(),
1773                "backtest/run-1/quotes_1.feather",
1774                &batches,
1775                false,
1776                Some("replay"),
1777            )
1778            .unwrap_err();
1779        assert_eq!(
1780            error.to_string(),
1781            "overlapping promotion groups have incompatible precision metadata"
1782        );
1783        assert!(
1784            catalog
1785                .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1786                .unwrap()
1787                .is_empty()
1788        );
1789    }
1790
1791    #[rstest]
1792    fn overlapping_groups_keep_source_order_for_equal_ts_init(
1793        #[values(false, true)] repeated_schema: bool,
1794    ) {
1795        let temp = TempDir::new().unwrap();
1796        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1797        let mut batches = vec![
1798            precision_batch("2", vec![1, 2], Some(10)),
1799            precision_batch("0", vec![2, 3], None),
1800        ];
1801
1802        if repeated_schema {
1803            batches.push(precision_batch("2", vec![2, 4], Some(30)));
1804        }
1805
1806        catalog
1807            .convert_feather_batches_to_parquet(
1808                Environment::Backtest,
1809                "run-1",
1810                &NautilusDataType::QuoteTick.into(),
1811                "backtest/run-1/quotes_1.feather",
1812                &batches,
1813                false,
1814                Some("replay"),
1815            )
1816            .unwrap();
1817
1818        let files = catalog
1819            .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1820            .unwrap();
1821
1822        let (written, _) = catalog
1823            .execute_async(|| async {
1824                read_parquet_from_object_store(
1825                    catalog.object_store.clone(),
1826                    &object_store::path::Path::from(files[0].as_str()),
1827                )
1828                .await
1829            })
1830            .unwrap();
1831
1832        assert!(
1833            written
1834                .iter()
1835                .all(|batch| batch.column_by_name(super::PROMOTION_ROW_INDEX).is_none())
1836        );
1837
1838        let rows = written
1839            .iter()
1840            .flat_map(|batch| {
1841                let ts_init = batch
1842                    .column_by_name("ts_init")
1843                    .unwrap()
1844                    .as_any()
1845                    .downcast_ref::<UInt64Array>()
1846                    .unwrap()
1847                    .values()
1848                    .to_vec();
1849                let price = batch
1850                    .column_by_name("price")
1851                    .unwrap()
1852                    .as_any()
1853                    .downcast_ref::<Decimal128Array>()
1854                    .unwrap()
1855                    .iter()
1856                    .collect::<Vec<_>>();
1857                ts_init.into_iter().zip(price)
1858            })
1859            .collect::<Vec<_>>();
1860
1861        assert_eq!(files.len(), 1);
1862
1863        let expected = if repeated_schema {
1864            vec![
1865                (1, Some(10)),
1866                (2, Some(10)),
1867                (2, None),
1868                (2, Some(30)),
1869                (3, None),
1870                (4, Some(30)),
1871            ]
1872        } else {
1873            vec![(1, Some(10)), (2, Some(10)), (2, None), (3, None)]
1874        };
1875
1876        assert_eq!(rows, expected);
1877    }
1878
1879    #[rstest]
1880    fn promote_feather_file_skips_staged_batches_without_rows() {
1881        let temp = TempDir::new().unwrap();
1882        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1883        let storage =
1884            create_storage_backend_from_path(temp.path().to_str().unwrap(), None).unwrap();
1885        let source = FeatherSessionSource::new(storage, Environment::Backtest, "run-1");
1886        let staged = RecordBatch::new_empty(Arc::new(Schema::new(vec![
1887            Field::new("ts_init", DataType::UInt64, false),
1888            Field::new(NAUTILUS_ARROW_METADATA_ID_COLUMN, DataType::Utf8, false),
1889            Field::new(NAUTILUS_ARROW_METADATA_JSON_COLUMN, DataType::Utf8, false),
1890        ])));
1891
1892        let summary = catalog
1893            .promote_feather_file(
1894                &source,
1895                "backtest/run-1/quotes_1.feather",
1896                vec![staged],
1897                false,
1898                "replay",
1899            )
1900            .unwrap();
1901
1902        assert!(summary.is_none());
1903        assert!(
1904            catalog
1905                .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1906                .unwrap()
1907                .is_empty()
1908        );
1909    }
1910
1911    #[rstest]
1912    fn overlapping_groups_with_different_fields_write_nothing() {
1913        let temp = TempDir::new().unwrap();
1914        let catalog = ParquetDataCatalog::new(temp.path(), None, None, None, None);
1915        let nullable = precision_batch("2", vec![2], Some(2));
1916        let schema = nullable.schema();
1917        let non_nullable = RecordBatch::try_new(
1918            Arc::new(Schema::new_with_metadata(
1919                vec![
1920                    schema.field(0).clone(),
1921                    schema.field(1).clone().with_nullable(false),
1922                ],
1923                schema.metadata().clone(),
1924            )),
1925            nullable.columns().to_vec(),
1926        )
1927        .unwrap();
1928        let batches = vec![precision_batch("2", vec![1, 3], Some(1)), non_nullable];
1929
1930        let error = catalog
1931            .convert_feather_batches_to_parquet(
1932                Environment::Backtest,
1933                "run-1",
1934                &NautilusDataType::QuoteTick.into(),
1935                "backtest/run-1/quotes_1.feather",
1936                &batches,
1937                false,
1938                Some("replay"),
1939            )
1940            .unwrap_err();
1941
1942        assert_eq!(
1943            error.to_string(),
1944            "overlapping promotion groups have incompatible schemas"
1945        );
1946        assert!(
1947            catalog
1948                .query_files(&NautilusDataType::QuoteTick.into(), None, None, None)
1949                .unwrap()
1950                .is_empty()
1951        );
1952    }
1953}
1954
1955#[cfg(test)]
1956mod stream_folder_tests {
1957    use nautilus_common::enums::Environment;
1958    use nautilus_model::data::NautilusDataType;
1959    use rstest::rstest;
1960
1961    use crate::{
1962        catalog::types::CatalogDataType, common::paths::catalog_data_type_from_session_feather_path,
1963    };
1964
1965    #[rstest]
1966    #[case("quotes", NautilusDataType::QuoteTick)]
1967    #[case("quote_tick", NautilusDataType::QuoteTick)]
1968    #[case("trades", NautilusDataType::TradeTick)]
1969    #[case("trade_tick", NautilusDataType::TradeTick)]
1970    #[case("bars", NautilusDataType::Bar)]
1971    #[case("bar", NautilusDataType::Bar)]
1972    #[case("order_book_deltas", NautilusDataType::OrderBookDelta)]
1973    #[case("order_book_delta", NautilusDataType::OrderBookDelta)]
1974    #[case("mark_prices", NautilusDataType::MarkPriceUpdate)]
1975    #[case("mark_price_update", NautilusDataType::MarkPriceUpdate)]
1976    #[case("index_prices", NautilusDataType::IndexPriceUpdate)]
1977    #[case("index_price_update", NautilusDataType::IndexPriceUpdate)]
1978    #[case("funding_rates", NautilusDataType::FundingRateUpdate)]
1979    #[case("funding_rate_update", NautilusDataType::FundingRateUpdate)]
1980    #[case("instrument_closes", NautilusDataType::InstrumentClose)]
1981    #[case("instrument_close", NautilusDataType::InstrumentClose)]
1982    fn stream_folders_parse_to_their_data_type(
1983        #[case] folder: &str,
1984        #[case] expected: NautilusDataType,
1985    ) {
1986        let path = format!("backtest/run-1/{folder}/{folder}_0.feather");
1987
1988        assert_eq!(
1989            catalog_data_type_from_session_feather_path(&path, Environment::Backtest, "run-1")
1990                .unwrap(),
1991            CatalogDataType::from(expected),
1992        );
1993    }
1994}
1995
1996#[cfg(test)]
1997mod read_run_tests {
1998    use std::{
1999        fs::{self, OpenOptions},
2000        sync::{Arc, atomic::AtomicU64},
2001    };
2002
2003    use nautilus_common::enums::Environment;
2004    use nautilus_core::UnixNanos;
2005    use nautilus_model::data::{
2006        Data, NautilusDataType, QuoteTick, TradeTick,
2007        stubs::{quote_audusd, quote_ethusdt_binance, stub_trade_ethusdt_buy},
2008    };
2009    use rstest::rstest;
2010    use tempfile::TempDir;
2011
2012    use crate::{
2013        backend::parquet::catalog::ParquetDataCatalog,
2014        catalog::types::CatalogDataType,
2015        writer::feather::{FEATHER_PARTIAL_EXTENSION, FeatherWriter, RotationConfig, WriterClock},
2016    };
2017
2018    fn quote_btc(ts: u64) -> QuoteTick {
2019        QuoteTick {
2020            instrument_id: "BTCUSDT-PERP.BINANCE".into(),
2021            ts_event: UnixNanos::from(ts),
2022            ts_init: UnixNanos::from(ts),
2023            ..quote_ethusdt_binance()
2024        }
2025    }
2026
2027    fn quote_aud(ts: u64) -> QuoteTick {
2028        QuoteTick {
2029            ts_event: UnixNanos::from(ts),
2030            ts_init: UnixNanos::from(ts),
2031            ..quote_audusd()
2032        }
2033    }
2034
2035    fn quote_eth(ts: u64) -> QuoteTick {
2036        QuoteTick {
2037            ts_event: UnixNanos::from(ts),
2038            ts_init: UnixNanos::from(ts),
2039            ..quote_ethusdt_binance()
2040        }
2041    }
2042
2043    fn trade_eth(ts: u64) -> TradeTick {
2044        TradeTick {
2045            ts_event: UnixNanos::from(ts),
2046            ts_init: UnixNanos::from(ts),
2047            ..stub_trade_ethusdt_buy()
2048        }
2049    }
2050
2051    fn run_writer(temp_dir: &TempDir) -> FeatherWriter {
2052        FeatherWriter::new(
2053            temp_dir.path().join("backtest").join("run-001"),
2054            WriterClock::Test(Arc::new(AtomicU64::new(0))),
2055            RotationConfig::NoRotation,
2056            None,
2057            None,
2058        )
2059    }
2060
2061    fn read_backtest(temp_dir: &TempDir) -> Vec<Data> {
2062        ParquetDataCatalog::new(temp_dir.path(), None, None, None, None)
2063            .read_backtest("run-001")
2064            .unwrap()
2065    }
2066
2067    // Leaves one flushed batch per quote in a partial file whose last batch is cut short, as a
2068    // writer that crashed mid-append would
2069    fn write_crashed_run(temp_dir: &TempDir, quotes: &[QuoteTick]) {
2070        let mut writer = run_writer(temp_dir);
2071
2072        for quote in quotes {
2073            writer.write_data(Data::Quote(*quote)).unwrap();
2074            writer.flush().unwrap();
2075        }
2076
2077        writer.close().unwrap();
2078
2079        let sealed = temp_dir
2080            .path()
2081            .join("backtest/run-001/quotes/quotes_0.feather");
2082        let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
2083        fs::rename(&sealed, &partial).unwrap();
2084
2085        // Drops the end-of-stream marker and the last byte of the final batch
2086        let file = OpenOptions::new().write(true).open(&partial).unwrap();
2087        file.set_len(file.metadata().unwrap().len() - 9).unwrap();
2088    }
2089
2090    #[rstest]
2091    #[case::backtest(ParquetDataCatalog::read_backtest)]
2092    #[case::live(ParquetDataCatalog::read_live_run)]
2093    fn read_run_rejects_path_bearing_ids(
2094        #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2095        #[values(
2096            "",
2097            ".",
2098            "..",
2099            "../run",
2100            "run/child",
2101            "run\\child",
2102            "/run",
2103            "\\run",
2104            "C:run",
2105            "C:/run",
2106            "C:\\run",
2107            "\\\\server\\share",
2108            "run:stream",
2109            "run\0id"
2110        )]
2111        instance_id: &str,
2112    ) {
2113        let temp_dir = TempDir::new().unwrap();
2114        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2115
2116        let e = reader(&catalog, instance_id).unwrap_err();
2117
2118        assert_eq!(
2119            e.to_string(),
2120            format!("Invalid run instance ID {instance_id:?}: expected a single path component"),
2121        );
2122    }
2123
2124    #[rstest]
2125    #[case::backtest(ParquetDataCatalog::read_backtest)]
2126    #[case::live(ParquetDataCatalog::read_live_run)]
2127    fn read_run_rejects_external_paths_before_recovery(
2128        #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2129        #[values(false, true)] absolute: bool,
2130    ) {
2131        let temp_dir = TempDir::new().unwrap();
2132        let outside = TempDir::new_in(temp_dir.path()).unwrap();
2133        let catalog_path = temp_dir.path().join("catalog");
2134        fs::create_dir_all(catalog_path.join("backtest")).unwrap();
2135        fs::create_dir_all(catalog_path.join("live")).unwrap();
2136        write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2137        let directory = outside.path().join("backtest/run-001");
2138        let partial = directory.join("quotes/quotes_0.feather.partial");
2139        let original = fs::read(&partial).unwrap();
2140
2141        let instance_id = if absolute {
2142            directory.to_str().unwrap().to_string()
2143        } else {
2144            format!(
2145                "../../{}/backtest/run-001",
2146                outside.path().file_name().unwrap().to_str().unwrap(),
2147            )
2148        };
2149
2150        let catalog = ParquetDataCatalog::new(&catalog_path, None, None, None, None);
2151
2152        let result = reader(&catalog, &instance_id);
2153
2154        assert!(result.is_err());
2155        assert_eq!(fs::read(&partial).unwrap(), original);
2156        assert!(!partial.with_extension("").exists());
2157    }
2158
2159    #[rstest]
2160    #[case::backtest(ParquetDataCatalog::read_backtest)]
2161    #[case::live(ParquetDataCatalog::read_live_run)]
2162    fn read_run_returns_no_data_for_missing_directories(
2163        #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2164        #[values("", "backtest", "backtest/run-001")] directory: &str,
2165    ) {
2166        let temp_dir = TempDir::new().unwrap();
2167        write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2168        let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2169        fs::remove_dir_all(temp_dir.path().join(directory)).unwrap();
2170
2171        let data = reader(&catalog, "run-001").unwrap();
2172        let conversion = catalog.convert_stream_to_data(
2173            "run-001",
2174            &CatalogDataType::from(NautilusDataType::QuoteTick),
2175            Environment::Backtest,
2176            None,
2177            false,
2178        );
2179
2180        assert_eq!(data, Vec::<Data>::new());
2181        conversion.unwrap();
2182        assert!(!temp_dir.path().join(directory).exists());
2183    }
2184
2185    #[rstest]
2186    fn convert_stream_to_data_rejects_path_bearing_ids() {
2187        let temp_dir = TempDir::new().unwrap();
2188        let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2189
2190        let e = catalog
2191            .convert_stream_to_data(
2192                "../run-001",
2193                &CatalogDataType::from(NautilusDataType::QuoteTick),
2194                Environment::Backtest,
2195                None,
2196                false,
2197            )
2198            .unwrap_err();
2199
2200        assert_eq!(
2201            e.to_string(),
2202            "Invalid run instance ID \"../run-001\": expected a single path component",
2203        );
2204    }
2205
2206    #[rstest]
2207    #[cfg(unix)]
2208    #[case::backtest(ParquetDataCatalog::read_backtest, "backtest")]
2209    #[case::live(ParquetDataCatalog::read_live_run, "live")]
2210    fn read_run_rejects_escaping_directory_symlinks(
2211        #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2212        #[case] environment: &str,
2213        #[values(false, true)] environment_symlink: bool,
2214    ) {
2215        let temp_dir = TempDir::new().unwrap();
2216        let outside = TempDir::new().unwrap();
2217        write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2218        let run = outside.path().join("backtest/run-001");
2219        let partial = run.join("quotes/quotes_0.feather.partial");
2220        let original = fs::read(&partial).unwrap();
2221        let root = temp_dir.path().join(environment);
2222        if environment_symlink {
2223            std::os::unix::fs::symlink(outside.path().join("backtest"), &root).unwrap();
2224        } else {
2225            fs::create_dir(&root).unwrap();
2226            std::os::unix::fs::symlink(&run, root.join("run-001")).unwrap();
2227        }
2228
2229        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2230        let result = reader(&catalog, "run-001");
2231
2232        assert!(result.is_err());
2233        assert_eq!(fs::read(&partial).unwrap(), original);
2234        assert!(!partial.with_extension("").exists());
2235    }
2236
2237    #[rstest]
2238    #[cfg(unix)]
2239    fn read_run_rejects_directory_symlinks_to_parent_roots(
2240        #[values(false, true)] environment_symlink: bool,
2241    ) {
2242        let temp_dir = TempDir::new().unwrap();
2243        write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2244        let partial = temp_dir
2245            .path()
2246            .join("backtest/run-001/quotes/quotes_0.feather.partial");
2247        let original = fs::read(&partial).unwrap();
2248        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2249        let result = if environment_symlink {
2250            std::os::unix::fs::symlink(temp_dir.path(), temp_dir.path().join("live")).unwrap();
2251            catalog.read_live_run("backtest")
2252        } else {
2253            let root = temp_dir.path().join("backtest");
2254            std::os::unix::fs::symlink(&root, root.join("run-alias")).unwrap();
2255            catalog.read_backtest("run-alias")
2256        };
2257
2258        assert!(result.is_err());
2259        assert_eq!(fs::read(&partial).unwrap(), original);
2260        assert!(!partial.with_extension("").exists());
2261    }
2262
2263    #[rstest]
2264    #[cfg(unix)]
2265    #[case::backtest(ParquetDataCatalog::read_backtest, "backtest")]
2266    #[case::live(ParquetDataCatalog::read_live_run, "live")]
2267    fn read_run_leaves_symlinked_recovery_targets_untouched(
2268        #[case] reader: fn(&ParquetDataCatalog, &str) -> anyhow::Result<Vec<Data>>,
2269        #[case] environment: &str,
2270        #[values(false, true)] directory_symlink: bool,
2271    ) {
2272        let temp_dir = TempDir::new().unwrap();
2273        let outside = TempDir::new().unwrap();
2274        write_crashed_run(&outside, &[quote_aud(1_000), quote_eth(2_000)]);
2275        let partial = outside
2276            .path()
2277            .join("backtest/run-001/quotes/quotes_0.feather.partial");
2278        let original = fs::read(&partial).unwrap();
2279        let run = temp_dir.path().join(environment).join("run-001");
2280        fs::create_dir_all(&run).unwrap();
2281        let link = if directory_symlink {
2282            let link = run.join("quotes");
2283            std::os::unix::fs::symlink(partial.parent().unwrap(), &link).unwrap();
2284            link
2285        } else {
2286            fs::create_dir(run.join("quotes")).unwrap();
2287            let link = run.join("quotes/quotes_0.feather.partial");
2288            std::os::unix::fs::symlink(&partial, &link).unwrap();
2289            link
2290        };
2291
2292        let target = fs::read_link(&link).unwrap();
2293        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2294
2295        let data = reader(&catalog, "run-001").unwrap();
2296
2297        assert_eq!(data, Vec::<Data>::new());
2298        assert_eq!(fs::read(&partial).unwrap(), original);
2299        assert_eq!(fs::read_link(&link).unwrap(), target);
2300        assert!(!partial.with_extension("").exists());
2301        assert!(!run.join("quotes/quotes_0.feather").exists());
2302    }
2303
2304    #[rstest]
2305    #[cfg(unix)]
2306    fn read_run_recovers_in_root_directory_symlinks(
2307        #[values(false, true)] environment_symlink: bool,
2308    ) {
2309        let temp_dir = TempDir::new().unwrap();
2310        write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2311        let run = temp_dir.path().join("backtest/run-001");
2312
2313        if environment_symlink {
2314            std::os::unix::fs::symlink(
2315                temp_dir.path().join("backtest"),
2316                temp_dir.path().join("live"),
2317            )
2318            .unwrap();
2319        } else {
2320            std::os::unix::fs::symlink(&run, temp_dir.path().join("backtest/run-alias")).unwrap();
2321        }
2322
2323        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2324        let data = if environment_symlink {
2325            catalog.read_live_run("run-001").unwrap()
2326        } else {
2327            catalog.read_backtest("run-alias").unwrap()
2328        };
2329
2330        assert_eq!(data, vec![Data::Quote(quote_aud(1_000))]);
2331        assert!(!run.join("quotes/quotes_0.feather.partial").exists());
2332        assert!(run.join("quotes/quotes_0.feather").exists());
2333    }
2334
2335    #[rstest]
2336    fn read_backtest_returns_every_record_sorted_by_ts_init() {
2337        let temp_dir = TempDir::new().unwrap();
2338        let mut writer = run_writer(&temp_dir);
2339
2340        for data in [
2341            Data::Quote(quote_eth(3_000)),
2342            Data::Quote(quote_aud(1_000)),
2343            Data::Trade(trade_eth(2_000)),
2344            Data::Quote(quote_aud(4_000)),
2345            Data::Quote(quote_btc(5_000)),
2346        ] {
2347            writer.write_data(data).unwrap();
2348        }
2349
2350        writer.close().unwrap();
2351
2352        assert_eq!(
2353            read_backtest(&temp_dir),
2354            vec![
2355                Data::Quote(quote_aud(1_000)),
2356                Data::Trade(trade_eth(2_000)),
2357                Data::Quote(quote_eth(3_000)),
2358                Data::Quote(quote_aud(4_000)),
2359                Data::Quote(quote_btc(5_000)),
2360            ],
2361        );
2362    }
2363
2364    #[rstest]
2365    #[case::selected_identifier_first(true)]
2366    #[case::every_identifier_first(false)]
2367    fn convert_stream_to_data_repeats_across_identifier_selections(#[case] selected_first: bool) {
2368        let temp_dir = TempDir::new().unwrap();
2369        let mut writer = run_writer(&temp_dir);
2370        writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2371        writer.write_data(Data::Quote(quote_eth(2_000))).unwrap();
2372        writer.close().unwrap();
2373
2374        // The second identifier in the shared file keeps its output identity when selected alone
2375        let selected = [quote_eth(2_000).instrument_id.to_string()];
2376
2377        let conversions = if selected_first {
2378            [Some(&selected[..]), None]
2379        } else {
2380            [None, Some(&selected[..])]
2381        };
2382
2383        let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2384
2385        for identifiers in conversions {
2386            catalog
2387                .convert_stream_to_data(
2388                    "run-001",
2389                    &CatalogDataType::from(NautilusDataType::QuoteTick),
2390                    Environment::Backtest,
2391                    identifiers,
2392                    false,
2393                )
2394                .unwrap();
2395        }
2396
2397        assert_eq!(
2398            catalog
2399                .query::<QuoteTick>(None, None, None, None, None, true)
2400                .unwrap(),
2401            vec![quote_aud(1_000), quote_eth(2_000)],
2402        );
2403    }
2404
2405    #[rstest]
2406    fn read_backtest_skips_open_partial_files() {
2407        let temp_dir = TempDir::new().unwrap();
2408        let mut writer = run_writer(&temp_dir);
2409        writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2410        writer.flush().unwrap();
2411
2412        let data = read_backtest(&temp_dir);
2413        writer.close().unwrap();
2414
2415        assert_eq!(data, Vec::<Data>::new());
2416        assert_eq!(
2417            read_backtest(&temp_dir),
2418            vec![Data::Quote(quote_aud(1_000))]
2419        );
2420    }
2421
2422    #[rstest]
2423    fn read_backtest_recovers_crashed_partial_files() {
2424        let temp_dir = TempDir::new().unwrap();
2425        write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2426
2427        assert_eq!(
2428            read_backtest(&temp_dir),
2429            vec![Data::Quote(quote_aud(1_000))]
2430        );
2431    }
2432
2433    #[rstest]
2434    fn convert_stream_to_data_recovers_crashed_partial_files() {
2435        let temp_dir = TempDir::new().unwrap();
2436        write_crashed_run(&temp_dir, &[quote_aud(1_000), quote_eth(2_000)]);
2437        let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
2438
2439        catalog
2440            .convert_stream_to_data(
2441                "run-001",
2442                &CatalogDataType::from(NautilusDataType::QuoteTick),
2443                Environment::Backtest,
2444                None,
2445                false,
2446            )
2447            .unwrap();
2448
2449        assert_eq!(
2450            catalog
2451                .query::<QuoteTick>(None, None, None, None, None, true)
2452                .unwrap(),
2453            vec![quote_aud(1_000)],
2454        );
2455    }
2456
2457    #[rstest]
2458    #[cfg(feature = "python")]
2459    fn read_backtest_reads_custom_data() {
2460        use nautilus_model::{
2461            data::{CustomData, DataType},
2462            identifiers::InstrumentId,
2463        };
2464        use nautilus_serialization::ensure_custom_data_registered;
2465
2466        use crate::test_data::RustTestCustomData;
2467
2468        ensure_custom_data_registered::<RustTestCustomData>();
2469        let instrument_id = InstrumentId::from("RUST.TEST");
2470
2471        let custom = Data::Custom(CustomData::new(
2472            Arc::new(RustTestCustomData {
2473                instrument_id,
2474                value: 1.23,
2475                flag: true,
2476                ts_event: UnixNanos::from(5_000),
2477                ts_init: UnixNanos::from(5_000),
2478            }),
2479            DataType::new("RustTestCustomData", None, Some(instrument_id.to_string())),
2480        ));
2481
2482        let temp_dir = TempDir::new().unwrap();
2483        let mut writer = run_writer(&temp_dir);
2484        writer.write_data(Data::Quote(quote_aud(1_000))).unwrap();
2485        writer.write_data(custom.clone()).unwrap();
2486        writer.close().unwrap();
2487
2488        assert_eq!(
2489            read_backtest(&temp_dir),
2490            vec![Data::Quote(quote_aud(1_000)), custom]
2491        );
2492    }
2493}