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