Skip to main content

nautilus_persistence/backend/
catalog.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//! Parquet data catalog for efficient storage and retrieval of financial market data.
17//!
18//! This module provides a data catalog implementation that uses Apache Parquet
19//! format for storing financial market data with object store backends. The catalog supports
20//! various data types including quotes, trades, bars, order book data, and other market events.
21//!
22//! # Key Features
23//!
24//! - **Object Store Integration**: Works with local filesystems, S3, and other object stores.
25//! - **Data Type Support**: Handles all major financial data types (quotes, trades, bars, etc.).
26//! - **Time-based Organization**: Organizes data by timestamp ranges for efficient querying.
27//! - **Consolidation**: Merges multiple files to optimize storage and query performance.
28//! - **Validation**: Ensures data integrity with timestamp ordering and interval validation.
29//!
30//! # Architecture
31//!
32//! The catalog organizes data in a hierarchical structure:
33//! ```text
34//! data/
35//! ├── quotes/
36//! │   └── INSTRUMENT_ID/
37//! │       └── start_ts-end_ts.parquet
38//! ├── trades/
39//! │   └── INSTRUMENT_ID/
40//! │       └── start_ts-end_ts.parquet
41//! └── bars/
42//!     └── INSTRUMENT_ID/
43//!         └── start_ts-end_ts.parquet
44//! ```
45//!
46//! # Usage
47//!
48//! ```rust,no_run
49//! use std::path::Path;
50//!
51//! use nautilus_persistence::backend::catalog::ParquetDataCatalog;
52//!
53//! // Create a new catalog
54//! let catalog = ParquetDataCatalog::new(
55//!     Path::new("/path/to/data"),
56//!     None,       // storage_options
57//!     Some(5000), // batch_size
58//!     None,       // compression (defaults to SNAPPY)
59//!     None,       // max_row_group_size (defaults to 5000)
60//! );
61//!
62//! // Write data to the catalog
63//! // catalog.write_to_parquet(&data, None, None, None)?;
64//! ```
65
66use std::{
67    borrow::Cow,
68    collections::{BTreeMap, HashMap, HashSet},
69    fmt::Debug,
70    io::Cursor,
71    ops::Bound as RangeBound,
72    path::{Path, PathBuf},
73    sync::Arc,
74};
75
76use ahash::AHashMap;
77use datafusion::arrow::{
78    array::{Array, UInt64Array},
79    compute::{SortOptions, concat_batches, sort_to_indices, take_record_batch},
80    record_batch::RecordBatch,
81};
82use futures::StreamExt;
83use indexmap::{IndexMap, IndexSet};
84use nautilus_common::live::get_runtime;
85use nautilus_core::{
86    UnixNanos,
87    datetime::{iso8601_to_unix_nanos, unix_nanos_to_iso8601},
88    string::{conversions::to_snake_case, urlencoding},
89};
90use nautilus_model::{
91    data::{
92        Bar, BarType, CustomData, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate,
93        InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth10,
94        QuoteTick, TradeTick, close::InstrumentClose, is_monotonically_increasing_by_init,
95        to_variant,
96    },
97    enums::AggregationSource,
98    events::{
99        AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
100        OrderEmulated, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
101        OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
102        OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
103        PositionChanged, PositionClosed, PositionOpened, PositionSnapshot,
104    },
105    instruments::InstrumentAny,
106    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
107};
108use nautilus_serialization::arrow::{
109    ArrowSchemaProvider, DecodeDataFromRecordBatch, DecodeTypedFromRecordBatch,
110    EncodeToRecordBatch, custom::CustomDataDecoder,
111};
112use object_store::{ObjectStore, ObjectStoreExt, path::Path as ObjectPath};
113use serde::Serialize;
114use unbounded_interval_tree::interval_tree::IntervalTree;
115
116use super::{
117    custom::{
118        custom_data_path_components, decode_batch_to_data as orchestration_decode_batch_to_data,
119        decode_custom_batches_to_data as orchestration_decode_custom_batches_to_data,
120        prepare_custom_data_batch,
121    },
122    session::{self, DataBackendSession, QueryResult, build_query},
123};
124use crate::parquet::{
125    append_path_to_file_uri, decode_object_store_segment, is_remote_uri_scheme,
126    read_parquet_from_object_store, remote_full_uri, remote_store_root_url,
127    write_batches_to_object_store,
128};
129
130/// A high-performance data catalog for storing and retrieving financial market data using Apache Parquet format.
131///
132/// The `ParquetDataCatalog` provides a solution for managing large volumes of financial
133/// market data with efficient storage, querying, and consolidation capabilities. It supports various
134/// object store backends including local filesystems, AWS S3, and other cloud storage providers.
135///
136/// # Features
137///
138/// - **Efficient Storage**: Uses Apache Parquet format with configurable compression.
139/// - **Object Store Backend**: Supports multiple storage backends through the `object_store` crate.
140/// - **Time-based Organization**: Organizes data by timestamp ranges for optimal query performance.
141/// - **Data Validation**: Ensures timestamp ordering and interval consistency.
142/// - **Consolidation**: Merges multiple files to reduce storage overhead and improve query speed.
143/// - **Type Safety**: Strongly typed data handling with compile-time guarantees.
144///
145/// # Data Organization
146///
147/// Data is organized hierarchically by data type and instrument:
148/// - `data/{data_type}/{instrument_id}/{start_ts}-{end_ts}.parquet`.
149/// - Files are named with their timestamp ranges for efficient range queries.
150/// - Intervals are validated to be disjoint to prevent data overlap.
151///
152/// # Performance Considerations
153///
154/// - **Batch Size**: Controls memory usage during data processing.
155/// - **Compression**: SNAPPY compression provides good balance of speed and size.
156/// - **Row Group Size**: Affects query performance and memory usage.
157/// - **File Consolidation**: Reduces the number of files for better query performance.
158pub struct ParquetDataCatalog {
159    /// The base path for data storage within the object store.
160    pub base_path: String,
161    /// The original URI provided when creating the catalog.
162    pub original_uri: String,
163    /// The object store backend for data persistence.
164    pub object_store: Arc<dyn ObjectStore>,
165    /// The DataFusion session for query execution.
166    pub session: DataBackendSession,
167    /// The number of records to process in each batch.
168    pub batch_size: usize,
169    /// The compression algorithm used for Parquet files.
170    pub compression: parquet::basic::Compression,
171    /// The maximum number of rows in each Parquet row group.
172    pub max_row_group_size: usize,
173}
174
175impl Debug for ParquetDataCatalog {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct(stringify!(ParquetDataCatalog))
178            .field("base_path", &self.base_path)
179            .finish_non_exhaustive()
180    }
181}
182
183impl ParquetDataCatalog {
184    /// Creates a new [`ParquetDataCatalog`] instance from a local file path.
185    ///
186    /// This is a convenience constructor that converts a local path to a URI format
187    /// and delegates to [`Self::from_uri`].
188    ///
189    /// # Parameters
190    ///
191    /// - `base_path`: The base directory path for data storage.
192    /// - `storage_options`: Optional `HashMap` containing storage-specific configuration options.
193    /// - `batch_size`: Number of records to process in each batch (default: 5000).
194    /// - `compression`: Parquet compression algorithm (default: SNAPPY).
195    /// - `max_row_group_size`: Maximum rows per Parquet row group (default: 5000).
196    ///
197    /// # Panics
198    ///
199    /// Panics if the path cannot be converted to a valid URI or if the object store
200    /// cannot be created from the path.
201    ///
202    /// # Examples
203    ///
204    /// ```rust,no_run
205    /// use std::path::Path;
206    ///
207    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
208    ///
209    /// let catalog = ParquetDataCatalog::new(
210    ///     Path::new("/tmp/nautilus_data"),
211    ///     None,       // no storage options
212    ///     Some(1000), // smaller batch size
213    ///     None,       // default compression
214    ///     None,       // default row group size
215    /// );
216    /// ```
217    #[must_use]
218    pub fn new(
219        base_path: &Path,
220        storage_options: Option<AHashMap<String, String>>,
221        batch_size: Option<usize>,
222        compression: Option<parquet::basic::Compression>,
223        max_row_group_size: Option<usize>,
224    ) -> Self {
225        let path_str = base_path.to_string_lossy().to_string();
226        Self::from_uri(
227            &path_str,
228            storage_options,
229            batch_size,
230            compression,
231            max_row_group_size,
232        )
233        .expect("Failed to create catalog from path")
234    }
235
236    /// Creates a new [`ParquetDataCatalog`] instance from a URI with optional storage options.
237    ///
238    /// Supports various URI schemes including local file paths and multiple cloud storage backends
239    /// supported by the `object_store` crate.
240    ///
241    /// # Supported URI Schemes
242    ///
243    /// - **AWS S3**: `s3://bucket/path`.
244    /// - **Google Cloud Storage**: `gs://bucket/path` or `gcs://bucket/path`.
245    /// - **Azure Blob Storage**: `az://container/path` or `abfs://container@account.dfs.core.windows.net/path`.
246    /// - **HTTP/WebDAV**: `http://` or `https://`.
247    /// - **Local files**: `file://path` or plain paths.
248    ///
249    /// # Parameters
250    ///
251    /// - `uri`: The URI for the data storage location.
252    /// - `storage_options`: Optional `HashMap` containing storage-specific configuration options:
253    ///   - For S3: `endpoint_url`, region, `access_key_id`, `secret_access_key`, `session_token`, etc.
254    ///   - For GCS: `service_account_path`, `service_account_key`, `project_id`, etc.
255    ///   - For Azure: `account_name`, `account_key`, `sas_token`, etc.
256    /// - `batch_size`: Number of records to process in each batch (default: 5000).
257    /// - `compression`: Parquet compression algorithm (default: SNAPPY).
258    /// - `max_row_group_size`: Maximum rows per Parquet row group (default: 5000).
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if:
263    /// - The URI format is invalid or unsupported.
264    /// - The object store cannot be created or accessed.
265    /// - Authentication fails for cloud storage backends.
266    ///
267    /// # Examples
268    ///
269    /// ```rust,no_run
270    /// use ahash::AHashMap;
271    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
272    ///
273    /// // Local filesystem
274    /// let local_catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
275    ///
276    /// // S3 bucket
277    /// let s3_catalog =
278    ///     ParquetDataCatalog::from_uri("s3://my-bucket/nautilus-data", None, None, None, None)?;
279    ///
280    /// // Google Cloud Storage
281    /// let gcs_catalog =
282    ///     ParquetDataCatalog::from_uri("gs://my-bucket/nautilus-data", None, None, None, None)?;
283    ///
284    /// // Azure Blob Storage
285    /// let azure_catalog =
286    ///     ParquetDataCatalog::from_uri("az://container/nautilus-data", None, None, None, None)?;
287    ///
288    /// // S3 with custom endpoint and credentials
289    /// let mut storage_options = AHashMap::new();
290    /// storage_options.insert(
291    ///     "endpoint_url".to_string(),
292    ///     "https://my-s3-endpoint.com".to_string(),
293    /// );
294    /// storage_options.insert("access_key_id".to_string(), "my-key".to_string());
295    /// storage_options.insert("secret_access_key".to_string(), "my-secret".to_string());
296    ///
297    /// let s3_catalog = ParquetDataCatalog::from_uri(
298    ///     "s3://my-bucket/nautilus-data",
299    ///     Some(storage_options),
300    ///     None,
301    ///     None,
302    ///     None,
303    /// )?;
304    /// # Ok::<(), anyhow::Error>(())
305    /// ```
306    pub fn from_uri(
307        uri: &str,
308        storage_options: Option<AHashMap<String, String>>,
309        batch_size: Option<usize>,
310        compression: Option<parquet::basic::Compression>,
311        max_row_group_size: Option<usize>,
312    ) -> anyhow::Result<Self> {
313        let batch_size = batch_size.unwrap_or(5000);
314        let compression = compression.unwrap_or(parquet::basic::Compression::SNAPPY);
315        let max_row_group_size = max_row_group_size.unwrap_or(5000);
316
317        let location =
318            crate::parquet::create_object_store_location_from_path(uri, storage_options)?;
319
320        Ok(Self {
321            base_path: location.base_path,
322            original_uri: location.original_uri,
323            object_store: location.object_store,
324            session: session::DataBackendSession::new(batch_size),
325            batch_size,
326            compression,
327            max_row_group_size,
328        })
329    }
330
331    /// Returns the base path of the catalog for testing purposes.
332    #[must_use]
333    pub fn get_base_path(&self) -> String {
334        self.base_path.clone()
335    }
336
337    /// Resets the backend session to clear any cached table registrations.
338    ///
339    /// This is useful during catalog operations when files are being modified
340    /// and we need to ensure fresh data is loaded.
341    pub fn reset_session(&mut self) {
342        self.session.clear_registered_tables();
343    }
344
345    /// Writes mixed data types to the catalog by separating them into type-specific collections.
346    ///
347    /// This method takes a heterogeneous collection of market data and separates it by type,
348    /// then writes each type to its appropriate location in the catalog. This is useful when
349    /// processing mixed data streams or bulk data imports.
350    ///
351    /// # Parameters
352    ///
353    /// - `data`: A vector of mixed [`Data`] enum variants.
354    /// - `start`: Optional start timestamp to override the data's natural range.
355    /// - `end`: Optional end timestamp to override the data's natural range.
356    ///
357    /// # Notes
358    ///
359    /// - Data is automatically sorted by type before writing.
360    /// - Each data type is written to its own directory structure.
361    /// - Instrument data handling is not yet implemented (TODO).
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if type-specific writes, custom data writes, or instrument
366    /// writes fail.
367    ///
368    /// # Examples
369    ///
370    /// ```rust,no_run
371    /// use nautilus_model::data::Data;
372    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
373    ///
374    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
375    /// let mixed_data: Vec<Data> = vec![/* mixed data types */];
376    ///
377    /// catalog.write_data_enum(&mixed_data, None, None, None)?;
378    /// # Ok::<(), anyhow::Error>(())
379    /// ```
380    #[allow(
381        clippy::match_wildcard_for_single_variants,
382        reason = "Data::Defi appears through nautilus-model feature unification"
383    )]
384    pub fn write_data_enum(
385        &self,
386        data: &[Data],
387        start: Option<UnixNanos>,
388        end: Option<UnixNanos>,
389        skip_disjoint_check: Option<bool>,
390    ) -> anyhow::Result<()> {
391        let mut deltas: Vec<OrderBookDelta> = Vec::new();
392        let mut depth10s: Vec<OrderBookDepth10> = Vec::new();
393        let mut quotes: Vec<QuoteTick> = Vec::new();
394        let mut trades: Vec<TradeTick> = Vec::new();
395        let mut bars: Vec<Bar> = Vec::new();
396        let mut mark_prices: Vec<MarkPriceUpdate> = Vec::new();
397        let mut index_prices: Vec<IndexPriceUpdate> = Vec::new();
398        let mut funding_rates: Vec<FundingRateUpdate> = Vec::new();
399        let mut option_greeks: Vec<OptionGreeks> = Vec::new();
400        let mut statuses: Vec<InstrumentStatus> = Vec::new();
401        let mut closes: Vec<InstrumentClose> = Vec::new();
402        // Group custom data by full DataType identity (type_name + identifier + metadata)
403        // so each batch is written to the correct path with consistent schema/metadata.
404        let custom_data_key = |c: &CustomData| {
405            (
406                c.data_type.type_name().to_string(),
407                c.data_type.identifier().map(String::from),
408                c.data_type.metadata_str(),
409            )
410        };
411        let mut custom_data: AHashMap<(String, Option<String>, String), Vec<CustomData>> =
412            AHashMap::new();
413
414        for d in data.iter().cloned() {
415            match d {
416                Data::Deltas(_) => {}
417                Data::Delta(d) => {
418                    deltas.push(d);
419                }
420                Data::Depth10(d) => {
421                    depth10s.push(*d);
422                }
423                Data::Quote(d) => {
424                    quotes.push(d);
425                }
426                Data::Trade(d) => {
427                    trades.push(d);
428                }
429                Data::Bar(d) => {
430                    bars.push(d);
431                }
432                Data::MarkPrice(p) => {
433                    mark_prices.push(p);
434                }
435                Data::IndexPrice(p) => {
436                    index_prices.push(p);
437                }
438                Data::FundingRate(p) => {
439                    funding_rates.push(p);
440                }
441                Data::OptionGreeks(g) => {
442                    option_greeks.push(g);
443                }
444                Data::InstrumentStatus(s) => {
445                    statuses.push(s);
446                }
447                Data::InstrumentClose(c) => {
448                    closes.push(c);
449                }
450                Data::Custom(c) => {
451                    custom_data.entry(custom_data_key(&c)).or_default().push(c);
452                }
453                #[cfg(feature = "defi")]
454                Data::Defi(_) => anyhow::bail!("Unsupported Data::Defi variant for catalog writes"),
455                #[allow(unreachable_patterns)]
456                _ => anyhow::bail!("Unsupported Data variant for catalog writes"),
457            }
458        }
459
460        // Instruments are handled separately via write_instruments method
461
462        // Group each type by its identity so one write never mixes identifiers:
463        // the target directory and schema metadata are taken from the first
464        // element, so a mixed write would silently re-label the rest
465        self.write_grouped_to_parquet(deltas, start, end, skip_disjoint_check, |d| {
466            d.instrument_id
467        })?;
468        self.write_grouped_to_parquet(depth10s, start, end, skip_disjoint_check, |d| {
469            d.instrument_id
470        })?;
471        self.write_grouped_to_parquet(quotes, start, end, skip_disjoint_check, |q| {
472            q.instrument_id
473        })?;
474        self.write_grouped_to_parquet(trades, start, end, skip_disjoint_check, |t| {
475            t.instrument_id
476        })?;
477        self.write_grouped_to_parquet(bars, start, end, skip_disjoint_check, |b| b.bar_type)?;
478        self.write_grouped_to_parquet(mark_prices, start, end, skip_disjoint_check, |p| {
479            p.instrument_id
480        })?;
481        self.write_grouped_to_parquet(index_prices, start, end, skip_disjoint_check, |p| {
482            p.instrument_id
483        })?;
484        self.write_grouped_to_parquet(funding_rates, start, end, skip_disjoint_check, |r| {
485            r.instrument_id
486        })?;
487        self.write_grouped_to_parquet(option_greeks, start, end, skip_disjoint_check, |g| {
488            g.instrument_id
489        })?;
490        self.write_grouped_to_parquet(statuses, start, end, skip_disjoint_check, |s| {
491            s.instrument_id
492        })?;
493        self.write_grouped_to_parquet(closes, start, end, skip_disjoint_check, |c| {
494            c.instrument_id
495        })?;
496
497        for (_, items) in custom_data {
498            self.write_custom_data_batch(items, start, end, skip_disjoint_check)?;
499        }
500
501        Ok(())
502    }
503
504    fn write_grouped_to_parquet<T, K, F>(
505        &self,
506        data: Vec<T>,
507        start: Option<UnixNanos>,
508        end: Option<UnixNanos>,
509        skip_disjoint_check: Option<bool>,
510        key: F,
511    ) -> anyhow::Result<()>
512    where
513        T: HasTsInit + EncodeToRecordBatch + CatalogPathPrefix,
514        K: Eq + std::hash::Hash,
515        F: Fn(&T) -> K,
516    {
517        let mut groups: IndexMap<K, Vec<T>> = IndexMap::new();
518
519        for item in data {
520            groups.entry(key(&item)).or_default().push(item);
521        }
522
523        for (_, items) in groups {
524            self.write_to_parquet(&items, start, end, skip_disjoint_check)?;
525        }
526
527        Ok(())
528    }
529
530    /// Writes typed data to a Parquet file in the catalog.
531    ///
532    /// This is the core method for persisting market data to the catalog. It handles data
533    /// validation, batching, compression, and ensures proper file organization with
534    /// timestamp-based naming.
535    ///
536    /// # Type Parameters
537    ///
538    /// - `T`: The data type to write, must implement required traits for serialization and cataloging.
539    ///
540    /// # Parameters
541    ///
542    /// - `data`: Data records to write (must be in ascending timestamp order).
543    /// - `start`: Optional start timestamp to override the natural data range.
544    /// - `end`: Optional end timestamp to override the natural data range.
545    ///
546    /// # Returns
547    ///
548    /// Returns the [`PathBuf`] of the created file, or an empty path if no data was provided.
549    /// If the target file already exists, returns the path without writing (skips write).
550    ///
551    /// # Errors
552    ///
553    /// Returns an error if:
554    /// - Data elements have mixed identities (instrument ID or bar type).
555    /// - Data serialization to Arrow record batches fails.
556    /// - Object store write operations fail.
557    /// - File path construction fails.
558    /// - Writing would create non-disjoint timestamp intervals.
559    ///
560    /// # Panics
561    ///
562    /// Panics if:
563    /// - Data timestamps are not in ascending order.
564    /// - Record batches are empty after conversion.
565    /// - Required metadata is missing from the schema.
566    ///
567    /// # Examples
568    ///
569    /// ```rust,no_run
570    /// use nautilus_model::data::QuoteTick;
571    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
572    ///
573    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
574    /// let quotes: Vec<QuoteTick> = vec![/* quote data */];
575    ///
576    /// let path = catalog.write_to_parquet(&quotes, None, None, None)?;
577    /// println!("Data written to: {:?}", path);
578    /// # Ok::<(), anyhow::Error>(())
579    /// ```
580    pub fn write_to_parquet<T>(
581        &self,
582        data: &[T],
583        start: Option<UnixNanos>,
584        end: Option<UnixNanos>,
585        skip_disjoint_check: Option<bool>,
586    ) -> anyhow::Result<PathBuf>
587    where
588        T: HasTsInit + EncodeToRecordBatch + CatalogPathPrefix,
589    {
590        if data.is_empty() {
591            return Ok(PathBuf::new());
592        }
593
594        let type_name = to_snake_case(std::any::type_name::<T>());
595        Self::check_ascending_timestamps(data, &type_name)?;
596
597        // The write directory and schema metadata come from the first element,
598        // so mixed identities would silently re-label everything after it
599        let first_metadata = data[0].metadata();
600        if let Some(position) = data
601            .iter()
602            .position(|item| item.metadata() != first_metadata)
603        {
604            anyhow::bail!(
605                "Cannot write {type_name} data with mixed identities: element {position} has \
606                 metadata {:?} but the first element has {first_metadata:?}; write each \
607                 instrument or bar type separately",
608                data[position].metadata(),
609            );
610        }
611
612        let start_ts = start.unwrap_or(data.first().unwrap().ts_init());
613        let end_ts = end.unwrap_or(data.last().unwrap().ts_init());
614
615        let batches = self.data_to_record_batches(data)?;
616        let schema = batches.first().expect("Batches are empty.").schema();
617
618        let identifier = if T::path_prefix() == "bars" {
619            schema.metadata.get("bar_type").cloned()
620        } else {
621            schema.metadata.get("instrument_id").cloned()
622        };
623
624        let directory = self.make_path(T::path_prefix(), identifier.as_deref())?;
625        let filename = timestamps_to_filename(start_ts, end_ts);
626        let path = PathBuf::from(format!("{directory}/{filename}"));
627        let object_path = self.to_object_path(&path.to_string_lossy())?;
628
629        let file_exists = self.execute_async(async {
630            let exists: bool = self.object_store.head(&object_path).await.is_ok();
631            Ok(exists)
632        })?;
633
634        if file_exists {
635            log::info!("File {} already exists, skipping write", path.display());
636            return Ok(path);
637        }
638
639        if !skip_disjoint_check.unwrap_or(false) {
640            let current_intervals = self.get_directory_intervals(&directory)?;
641            let new_interval = (start_ts.as_u64(), end_ts.as_u64());
642            let mut new_intervals = current_intervals.clone();
643            new_intervals.push(new_interval);
644
645            if !are_intervals_disjoint(&new_intervals) {
646                anyhow::bail!(
647                    "Writing file {filename} with interval ({start_ts}, {end_ts}) would create \
648                    non-disjoint intervals. Existing intervals: {current_intervals:?}"
649                );
650            }
651        }
652
653        log::info!(
654            "Writing {} batches of {type_name} data to {}",
655            batches.len(),
656            path.display(),
657        );
658
659        self.execute_async(async {
660            write_batches_to_object_store(
661                &batches,
662                self.object_store.clone(),
663                &object_path,
664                Some(self.compression),
665                Some(self.max_row_group_size),
666                None,
667            )
668            .await
669        })?;
670
671        Ok(path)
672    }
673
674    /// Writes custom data to a Parquet file in the catalog.
675    ///
676    /// This method handles writing custom data types that implement `CustomDataTrait`.
677    /// Custom data is organized by type name in a `custom/{type_name}/` directory structure.
678    ///
679    /// # Parameters
680    ///
681    /// - `data`: Vector of custom data items to write (must be in ascending timestamp order).
682    /// - `start`: Optional start timestamp to override the natural data range.
683    /// - `end`: Optional end timestamp to override the natural data range.
684    /// - `skip_disjoint_check`: Whether to skip interval disjointness validation.
685    ///
686    /// # Returns
687    ///
688    /// Returns the [`PathBuf`] of the created file, or an empty path if no data was provided.
689    ///
690    /// # Errors
691    ///
692    /// Returns an error if:
693    /// - Data serialization to Arrow record batches fails.
694    /// - Object store write operations fail.
695    /// - File path construction fails.
696    /// - Writing would create non-disjoint timestamp intervals (unless skipped).
697    pub fn write_custom_data_batch(
698        &self,
699        data: Vec<CustomData>,
700        start: Option<UnixNanos>,
701        end: Option<UnixNanos>,
702        skip_disjoint_check: Option<bool>,
703    ) -> anyhow::Result<PathBuf> {
704        if data.is_empty() {
705            return Ok(PathBuf::new());
706        }
707
708        let (batch, type_name, identifier, start_ts, end_ts) = prepare_custom_data_batch(data)?;
709        let start_ts = start.unwrap_or(start_ts);
710        let end_ts = end.unwrap_or(end_ts);
711        let batches = vec![batch];
712
713        let directory = self.make_path_custom_data(&type_name, identifier.as_deref())?;
714        let filename = timestamps_to_filename(start_ts, end_ts);
715        let path = PathBuf::from(format!("{directory}/{filename}"));
716        let object_path = self.to_object_path(&path.to_string_lossy())?;
717
718        let file_exists = self.execute_async(async {
719            let exists: bool = self.object_store.head(&object_path).await.is_ok();
720            Ok(exists)
721        })?;
722
723        if file_exists {
724            log::info!("File {} already exists, skipping write", path.display());
725            return Ok(path);
726        }
727
728        if !skip_disjoint_check.unwrap_or(false) {
729            let current_intervals = self.get_directory_intervals(&directory)?;
730            let new_interval = (start_ts.as_u64(), end_ts.as_u64());
731            let mut new_intervals = current_intervals.clone();
732            new_intervals.push(new_interval);
733
734            if !are_intervals_disjoint(&new_intervals) {
735                anyhow::bail!(
736                    "Writing file {filename} with interval ({start_ts}, {end_ts}) would create \
737                    non-disjoint intervals. Existing intervals: {current_intervals:?}"
738                );
739            }
740        }
741
742        self.execute_async(async {
743            write_batches_to_object_store(
744                &batches,
745                self.object_store.clone(),
746                &object_path,
747                Some(self.compression),
748                Some(self.max_row_group_size),
749                None,
750            )
751            .await
752        })?;
753
754        Ok(path)
755    }
756
757    /// Writes instruments to Parquet files in the catalog.
758    ///
759    /// Instruments are stored under their instrument ID directory using timestamp-ranged
760    /// file names, allowing multiple historical versions of the same instrument to be
761    /// appended over time:
762    /// `data/instruments/{instrument_id}/{start_ts}-{end_ts}.parquet`
763    ///
764    /// # Parameters
765    ///
766    /// - `instruments`: Vector of instruments to write.
767    ///
768    /// # Returns
769    ///
770    /// Returns a vector of paths to the created files.
771    ///
772    /// # Errors
773    ///
774    /// Returns an error if:
775    /// - Data serialization fails.
776    /// - Object store write operations fail.
777    /// - File path construction fails.
778    ///
779    /// # Examples
780    ///
781    /// ```rust,no_run
782    /// use nautilus_model::instruments::InstrumentAny;
783    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
784    ///
785    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
786    /// let instruments: Vec<InstrumentAny> = vec![/* instruments */];
787    ///
788    /// let paths = catalog.write_instruments(instruments)?;
789    /// # Ok::<(), anyhow::Error>(())
790    /// ```
791    pub fn write_instruments(
792        &self,
793        instruments: Vec<InstrumentAny>,
794    ) -> anyhow::Result<Vec<PathBuf>> {
795        use nautilus_model::instruments::Instrument;
796
797        if instruments.is_empty() {
798            return Ok(Vec::new());
799        }
800
801        // Group instruments by concrete type and instrument_id so mixed InstrumentAny
802        // inputs are written as separate parquet batches with stable ordering.
803        let mut by_type_and_id: BTreeMap<(String, String), Vec<InstrumentAny>> = BTreeMap::new();
804
805        for instrument in instruments {
806            let instrument_type = Self::instrument_type_name(&instrument).to_string();
807            let instrument_id = Instrument::id(&instrument).to_string();
808            by_type_and_id
809                .entry((instrument_type, instrument_id))
810                .or_default()
811                .push(instrument);
812        }
813
814        let mut paths = Vec::new();
815
816        for ((_instrument_type, instrument_id), instrument_group) in by_type_and_id {
817            Self::check_ascending_timestamps(&instrument_group, "instrument")?;
818
819            let Some(first_instrument) = instrument_group.first() else {
820                continue;
821            };
822            let Some(last_instrument) = instrument_group.last() else {
823                continue;
824            };
825            let start_ts = HasTsInit::ts_init(first_instrument);
826            let end_ts = HasTsInit::ts_init(last_instrument);
827            let batches = self.data_to_record_batches(&instrument_group)?;
828            if batches.is_empty() {
829                continue;
830            }
831
832            let directory = self.make_path("instruments", Some(instrument_id.as_str()))?;
833            let filename = timestamps_to_filename(start_ts, end_ts);
834            let path = PathBuf::from(format!("{directory}/{filename}"));
835            let object_path = self.to_object_path(&path.to_string_lossy())?;
836
837            let file_exists = self
838                .execute_async(async { Ok(self.object_store.head(&object_path).await.is_ok()) })?;
839
840            if file_exists {
841                log::info!(
842                    "Instrument file {} already exists, skipping write",
843                    path.display()
844                );
845                paths.push(path);
846                continue;
847            }
848
849            let current_intervals = self.get_directory_intervals(&directory)?;
850            let new_interval = (start_ts.as_u64(), end_ts.as_u64());
851            let mut new_intervals = current_intervals.clone();
852            new_intervals.push(new_interval);
853
854            if !are_intervals_disjoint(&new_intervals) {
855                anyhow::bail!(
856                    "Writing file {filename} with interval ({start_ts}, {end_ts}) would create \
857                    non-disjoint intervals. Existing intervals: {current_intervals:?}"
858                );
859            }
860
861            log::info!(
862                "Writing {} batches of instrument data for {instrument_id} to {}",
863                batches.len(),
864                path.display(),
865            );
866
867            // ArrowWriter stores the full schema (including "class" metadata) in ARROW:schema.
868            // When reading, use the builder's schema for metadata (see query_instruments).
869            self.execute_async(async {
870                write_batches_to_object_store(
871                    &batches,
872                    self.object_store.clone(),
873                    &object_path,
874                    Some(self.compression),
875                    Some(self.max_row_group_size),
876                    None,
877                )
878                .await
879            })?;
880
881            paths.push(path);
882        }
883
884        Ok(paths)
885    }
886
887    /// Queries instruments from the catalog.
888    ///
889    /// Instruments are stored by instrument ID in timestamp-ranged parquet files under
890    /// `data/instruments/{instrument_id}/`. Legacy `instrument.parquet` files are still
891    /// supported for backwards compatibility.
892    ///
893    /// # Parameters
894    ///
895    /// - `instrument_ids`: Optional list of instrument IDs to filter by. If `None`, queries all instruments.
896    ///
897    /// # Returns
898    ///
899    /// Returns a vector of `InstrumentAny` instances, or an error if the operation fails.
900    ///
901    /// # Errors
902    ///
903    /// Returns an error if:
904    /// - File discovery fails.
905    /// - File reading fails.
906    /// - Data deserialization fails.
907    ///
908    /// # Examples
909    ///
910    /// ```rust,no_run
911    /// use nautilus_model::instruments::InstrumentAny;
912    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
913    ///
914    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
915    ///
916    /// // Query all instruments
917    /// let instruments = catalog.query_instruments(None)?;
918    ///
919    /// // Query specific instruments
920    /// let instruments = catalog.query_instruments(Some(&["EUR/USD.SIM".to_string()]))?;
921    /// # Ok::<(), anyhow::Error>(())
922    /// ```
923    pub fn query_instruments(
924        &self,
925        instrument_ids: Option<&[String]>,
926    ) -> anyhow::Result<Vec<InstrumentAny>> {
927        self.query_instruments_filtered(instrument_ids, None, None)
928    }
929
930    /// Queries instruments from the catalog with optional timestamp filtering.
931    ///
932    /// This reads all matching parquet files under `data/instruments/{instrument_id}/`,
933    /// including legacy `instrument.parquet` files, decodes the records back to
934    /// `InstrumentAny`, and filters them by `ts_init` when a range is provided.
935    ///
936    /// # Errors
937    ///
938    /// Returns an error if path construction, object store listing, parquet reads, or
939    /// instrument decoding fails.
940    pub fn query_instruments_filtered(
941        &self,
942        instrument_ids: Option<&[String]>,
943        start: Option<UnixNanos>,
944        end: Option<UnixNanos>,
945    ) -> anyhow::Result<Vec<InstrumentAny>> {
946        use nautilus_serialization::arrow::instrument::decode_instrument_any_batch;
947
948        let base_dir = self.make_path("instruments", None)?;
949        let mut all_instruments = Vec::new();
950        let start_u64 = start.map(|ts| ts.as_u64());
951        let end_u64 = end.map(|ts| ts.as_u64());
952
953        let list_result = self.execute_async(async {
954            let prefix = ObjectPath::from(format!("{base_dir}/"));
955            let mut stream = self.object_store.list(Some(&prefix));
956            let mut objects = Vec::new();
957            while let Some(object) = stream.next().await {
958                objects.push(object?);
959            }
960            Ok::<Vec<_>, anyhow::Error>(objects)
961        })?;
962
963        let mut instrument_files = Vec::new();
964
965        for object in list_result {
966            let path_str = object.location.to_string();
967            if !path_str.ends_with(".parquet") {
968                continue;
969            }
970
971            let path_parts: Vec<&str> = path_str.split('/').collect();
972            if path_parts.len() < 2 {
973                continue;
974            }
975
976            let instrument_id_dir = decode_object_store_segment(path_parts[path_parts.len() - 2]);
977
978            if let Some(ids) = instrument_ids
979                && !ids
980                    .iter()
981                    .map(|id| urisafe_instrument_id(id))
982                    .any(|x| x.as_str() == urisafe_instrument_id(&instrument_id_dir))
983            {
984                continue;
985            }
986
987            let include_file = if path_str.ends_with("/instrument.parquet") {
988                true
989            } else {
990                query_intersects_filename(&path_str, start_u64, end_u64)
991            };
992
993            if include_file {
994                instrument_files.push(path_str);
995            }
996        }
997
998        instrument_files.sort();
999
1000        for file_path in instrument_files {
1001            let object_path = self.to_object_path_parsed(&file_path)?;
1002            let (batches, builder_schema) = self.execute_async(async {
1003                read_parquet_from_object_store(self.object_store.clone(), &object_path).await
1004            })?;
1005
1006            let metadata: std::collections::HashMap<String, String> =
1007                builder_schema.metadata().clone();
1008
1009            for batch in batches {
1010                let mut instruments = decode_instrument_any_batch(&metadata, &batch)?;
1011
1012                if start.is_some() || end.is_some() {
1013                    instruments.retain(|instrument| {
1014                        let ts = HasTsInit::ts_init(instrument).as_u64();
1015                        start_u64.is_none_or(|value| ts >= value)
1016                            && end_u64.is_none_or(|value| ts <= value)
1017                    });
1018                }
1019                all_instruments.extend(instruments);
1020            }
1021        }
1022
1023        all_instruments.sort_by_key(HasTsInit::ts_init);
1024
1025        Ok(all_instruments)
1026    }
1027
1028    /// Writes typed data to a JSON file in the catalog.
1029    ///
1030    /// This method provides an alternative to Parquet format for data export and debugging.
1031    /// JSON files are human-readable but less efficient for large datasets.
1032    ///
1033    /// # Type Parameters
1034    ///
1035    /// - `T`: The data type to write, must implement serialization and cataloging traits.
1036    ///
1037    /// # Parameters
1038    ///
1039    /// - `data`: Vector of data records to write (must be in ascending timestamp order).
1040    /// - `path`: Optional custom directory path (defaults to catalog's standard structure).
1041    /// - `write_metadata`: Whether to write a separate metadata file alongside the data.
1042    ///
1043    /// # Returns
1044    ///
1045    /// Returns the [`PathBuf`] of the created JSON file.
1046    ///
1047    /// # Errors
1048    ///
1049    /// Returns an error if:
1050    /// - JSON serialization fails.
1051    /// - Object store write operations fail.
1052    /// - File path construction fails.
1053    ///
1054    /// # Panics
1055    ///
1056    /// Panics if data timestamps are not in ascending order.
1057    ///
1058    /// # Examples
1059    ///
1060    /// ```rust,no_run
1061    /// use std::path::PathBuf;
1062    /// use nautilus_model::data::TradeTick;
1063    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1064    ///
1065    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
1066    /// let trades: Vec<TradeTick> = vec![/* trade data */];
1067    ///
1068    /// let path = catalog.write_to_json(
1069    ///     trades,
1070    ///     Some(PathBuf::from("/custom/path")),
1071    ///     true  // write metadata
1072    /// )?;
1073    /// # Ok::<(), anyhow::Error>(())
1074    /// ```
1075    pub fn write_to_json<T>(
1076        &self,
1077        data: Vec<T>,
1078        path: Option<PathBuf>,
1079        write_metadata: bool,
1080    ) -> anyhow::Result<PathBuf>
1081    where
1082        T: HasTsInit + Serialize + CatalogPathPrefix + EncodeToRecordBatch,
1083    {
1084        if data.is_empty() {
1085            return Ok(PathBuf::new());
1086        }
1087
1088        let type_name = to_snake_case(std::any::type_name::<T>());
1089        Self::check_ascending_timestamps(&data, &type_name)?;
1090
1091        let start_ts = data.first().unwrap().ts_init();
1092        let end_ts = data.last().unwrap().ts_init();
1093
1094        let directory =
1095            path.unwrap_or_else(|| PathBuf::from(self.make_path(T::path_prefix(), None).unwrap()));
1096        let filename = timestamps_to_filename(start_ts, end_ts).replace(".parquet", ".json");
1097        let json_path = directory.join(&filename);
1098
1099        log::info!(
1100            "Writing {} records of {type_name} data to {}",
1101            data.len(),
1102            json_path.display(),
1103        );
1104
1105        if write_metadata {
1106            let metadata = T::chunk_metadata(&data);
1107            let metadata_path = json_path.with_extension("metadata.json");
1108            log::info!("Writing metadata to {}", metadata_path.display());
1109
1110            // Use object store for metadata file
1111            let metadata_object_path = ObjectPath::from(metadata_path.to_string_lossy().as_ref());
1112            let metadata_json = serde_json::to_vec_pretty(&metadata)?;
1113            self.execute_async(async {
1114                let _: object_store::PutResult = self
1115                    .object_store
1116                    .put(&metadata_object_path, metadata_json.into())
1117                    .await?;
1118                Ok(())
1119            })?;
1120        }
1121
1122        // Use object store for main JSON file
1123        let json_object_path = ObjectPath::from(json_path.to_string_lossy().as_ref());
1124        let json_data = serde_json::to_vec_pretty(&serde_json::to_value(data)?)?;
1125        self.execute_async(async {
1126            let _: object_store::PutResult = self
1127                .object_store
1128                .put(&json_object_path, json_data.into())
1129                .await?;
1130            Ok(())
1131        })?;
1132
1133        Ok(json_path)
1134    }
1135
1136    /// Validates that data timestamps are in ascending order.
1137    ///
1138    /// # Parameters
1139    ///
1140    /// - `data`: Slice of data records to validate.
1141    /// - `type_name`: Name of the data type for error messages.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Returns an error if any adjacent timestamps are out of ascending order.
1146    pub fn check_ascending_timestamps<T: HasTsInit>(
1147        data: &[T],
1148        type_name: &str,
1149    ) -> anyhow::Result<()> {
1150        if !data
1151            .array_windows()
1152            .all(|[a, b]| a.ts_init() <= b.ts_init())
1153        {
1154            anyhow::bail!("{type_name} timestamps must be in ascending order");
1155        }
1156
1157        Ok(())
1158    }
1159
1160    fn instrument_type_name(instrument: &InstrumentAny) -> &'static str {
1161        match instrument {
1162            InstrumentAny::Betting(_) => "BettingInstrument",
1163            InstrumentAny::BinaryOption(_) => "BinaryOption",
1164            InstrumentAny::Cfd(_) => "Cfd",
1165            InstrumentAny::Commodity(_) => "Commodity",
1166            InstrumentAny::CryptoFuture(_) => "CryptoFuture",
1167            InstrumentAny::CryptoFuturesSpread(_) => "CryptoFuturesSpread",
1168            InstrumentAny::CryptoOption(_) => "CryptoOption",
1169            InstrumentAny::CryptoOptionSpread(_) => "CryptoOptionSpread",
1170            InstrumentAny::CryptoPerpetual(_) => "CryptoPerpetual",
1171            InstrumentAny::CurrencyPair(_) => "CurrencyPair",
1172            InstrumentAny::Equity(_) => "Equity",
1173            InstrumentAny::FuturesContract(_) => "FuturesContract",
1174            InstrumentAny::FuturesSpread(_) => "FuturesSpread",
1175            InstrumentAny::IndexInstrument(_) => "IndexInstrument",
1176            InstrumentAny::OptionContract(_) => "OptionContract",
1177            InstrumentAny::OptionSpread(_) => "OptionSpread",
1178            InstrumentAny::PerpetualContract(_) => "PerpetualContract",
1179            InstrumentAny::TokenizedAsset(_) => "TokenizedAsset",
1180        }
1181    }
1182
1183    /// Converts data into Arrow record batches for Parquet serialization.
1184    ///
1185    /// This method chunks the data according to the configured batch size and converts
1186    /// each chunk into an Arrow record batch with appropriate metadata.
1187    ///
1188    /// # Type Parameters
1189    ///
1190    /// - `T`: The data type to convert, must implement required encoding traits.
1191    ///
1192    /// # Parameters
1193    ///
1194    /// - `data`: Data records to convert.
1195    ///
1196    /// # Returns
1197    ///
1198    /// Returns a vector of Arrow [`RecordBatch`] instances ready for Parquet serialization.
1199    ///
1200    /// # Errors
1201    ///
1202    /// Returns an error if record batch encoding fails for any chunk.
1203    pub fn data_to_record_batches<T>(&self, data: &[T]) -> anyhow::Result<Vec<RecordBatch>>
1204    where
1205        T: HasTsInit + EncodeToRecordBatch,
1206    {
1207        let mut batches = Vec::new();
1208
1209        for chunk in data.chunks(self.batch_size) {
1210            let metadata = EncodeToRecordBatch::chunk_metadata(chunk);
1211            let record_batch = T::encode_batch(&metadata, chunk)?;
1212            batches.push(record_batch);
1213        }
1214
1215        Ok(batches)
1216    }
1217
1218    /// Extends the timestamp range of an existing Parquet file by renaming it.
1219    ///
1220    /// This method finds an existing file that is adjacent to the specified time range
1221    /// and renames it to include the new range. This is useful when appending data
1222    /// that extends the time coverage of existing files.
1223    ///
1224    /// # Parameters
1225    ///
1226    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
1227    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1228    /// - `start`: Start timestamp of the new range to extend to.
1229    /// - `end`: End timestamp of the new range to extend to.
1230    ///
1231    /// # Returns
1232    ///
1233    /// Returns `Ok(())` on success, or an error if the operation fails.
1234    ///
1235    /// # Errors
1236    ///
1237    /// Returns an error if:
1238    /// - The directory path cannot be constructed.
1239    /// - No adjacent file is found to extend.
1240    /// - File rename operations fail.
1241    /// - Interval validation fails after extension.
1242    ///
1243    /// # Examples
1244    ///
1245    /// ```rust,no_run
1246    /// use nautilus_core::UnixNanos;
1247    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1248    ///
1249    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
1250    ///
1251    /// // Extend a file's range backwards or forwards
1252    /// catalog.extend_file_name(
1253    ///     "quotes",
1254    ///     Some("BTC/USD.SIM"),
1255    ///     UnixNanos::from(1609459200000000000),
1256    ///     UnixNanos::from(1609545600000000000),
1257    /// )?;
1258    /// # Ok::<(), anyhow::Error>(())
1259    /// ```
1260    pub fn extend_file_name(
1261        &self,
1262        data_cls: &str,
1263        identifier: Option<&str>,
1264        start: UnixNanos,
1265        end: UnixNanos,
1266    ) -> anyhow::Result<()> {
1267        let directory = self.make_path(data_cls, identifier)?;
1268        let intervals = self.get_directory_intervals(&directory)?;
1269
1270        let start = start.as_u64();
1271        let end = end.as_u64();
1272
1273        for interval in intervals {
1274            if interval.0 == end + 1 {
1275                // Extend backwards: new file covers [start, interval.1]
1276                self.rename_parquet_file(&directory, interval.0, interval.1, start, interval.1)?;
1277                break;
1278            } else if interval.1 == start - 1 {
1279                // Extend forwards: new file covers [interval.0, end]
1280                self.rename_parquet_file(&directory, interval.0, interval.1, interval.0, end)?;
1281                break;
1282            }
1283        }
1284
1285        let intervals = self.get_directory_intervals(&directory)?;
1286
1287        if !are_intervals_disjoint(&intervals) {
1288            anyhow::bail!("Intervals are not disjoint after extending a file");
1289        }
1290
1291        Ok(())
1292    }
1293
1294    /// Lists all Parquet files in a specified directory.
1295    ///
1296    /// This method scans a directory and returns the full paths of all files with the `.parquet`
1297    /// extension. It works with both local filesystems and remote object stores, making it
1298    /// suitable for various storage backends.
1299    ///
1300    /// # Parameters
1301    ///
1302    /// - `directory`: The directory path to scan for Parquet files.
1303    ///
1304    /// # Returns
1305    ///
1306    /// Returns a vector of full file paths (as strings) for all Parquet files found in the directory.
1307    /// The paths are relative to the object store root and suitable for use with object store operations.
1308    /// Returns an empty vector if the directory doesn't exist or contains no Parquet files.
1309    ///
1310    /// # Errors
1311    ///
1312    /// Returns an error if:
1313    /// - Object store listing operations fail.
1314    /// - Directory access is denied.
1315    /// - Network issues occur (for remote object stores).
1316    ///
1317    /// # Notes
1318    ///
1319    /// - Only files ending with `.parquet` are included.
1320    /// - Subdirectories are not recursively scanned.
1321    /// - File paths are returned in the order provided by the object store.
1322    /// - Works with all supported object store backends (local, S3, GCS, Azure, etc.).
1323    ///
1324    /// # Examples
1325    ///
1326    /// ```rust,no_run
1327    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1328    ///
1329    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
1330    /// let files = catalog.list_parquet_files("data/quotes/EURUSD")?;
1331    ///
1332    /// for file in files {
1333    ///     println!("Found Parquet file: {}", file);
1334    /// }
1335    /// # Ok::<(), anyhow::Error>(())
1336    /// ```
1337    pub fn list_parquet_files(&self, directory: &str) -> anyhow::Result<Vec<String>> {
1338        self.execute_async(async {
1339            let prefix = ObjectPath::from(format!("{directory}/"));
1340            let mut stream = self.object_store.list(Some(&prefix));
1341            let mut files = Vec::new();
1342
1343            while let Some(object) = stream.next().await {
1344                let object = object?;
1345                if object.location.as_ref().ends_with(".parquet") {
1346                    files.push(object.location.to_string());
1347                }
1348            }
1349            Ok::<Vec<String>, anyhow::Error>(files)
1350        })
1351    }
1352
1353    /// Lists all instrument identifiers for a specific data type.
1354    ///
1355    /// This method scans the data directory for a given data type and extracts
1356    /// all unique instrument identifiers from the directory structure.
1357    ///
1358    /// # Parameters
1359    ///
1360    /// - `data_type`: The data type directory name (e.g., "quotes", "trades", "bars").
1361    ///
1362    /// # Returns
1363    ///
1364    /// Returns a vector of instrument identifier strings.
1365    ///
1366    /// # Errors
1367    ///
1368    /// Returns an error if directory listing fails.
1369    pub fn list_instruments(&self, data_type: &str) -> anyhow::Result<Vec<String>> {
1370        self.execute_async(async {
1371            let prefix = ObjectPath::from(format!("data/{data_type}/"));
1372            let mut stream = self.object_store.list(Some(&prefix));
1373            let mut instruments = HashSet::new();
1374
1375            while let Some(object) = stream.next().await {
1376                let object = object?;
1377                let path = object.location.as_ref();
1378                let parts: Vec<&str> = path.split('/').collect();
1379                if parts.len() >= 3 {
1380                    instruments.insert(parts[2].to_string());
1381                }
1382            }
1383            Ok::<Vec<String>, anyhow::Error>(instruments.into_iter().collect())
1384        })
1385    }
1386
1387    /// Lists Parquet files matching specific criteria (data type, identifiers, time range).
1388    ///
1389    /// This method finds all Parquet files that match the specified criteria by filtering
1390    /// files based on their directory structure and filename timestamps.
1391    ///
1392    /// # Parameters
1393    ///
1394    /// - `data_type`: The data type directory name (e.g., "quotes", "trades", "custom/MyType").
1395    /// - `identifiers`: Optional list of identifiers to filter by.
1396    /// - `start`: Optional start timestamp to filter files by their time range.
1397    /// - `end`: Optional end timestamp to filter files by their time range.
1398    ///
1399    /// # Returns
1400    ///
1401    /// Returns a vector of file paths that match the criteria.
1402    ///
1403    /// # Errors
1404    ///
1405    /// Returns an error if directory listing or file filtering fails.
1406    pub fn list_parquet_files_with_criteria(
1407        &self,
1408        data_type: &str,
1409        identifiers: Option<&[String]>,
1410        start: Option<UnixNanos>,
1411        end: Option<UnixNanos>,
1412    ) -> anyhow::Result<Vec<String>> {
1413        let mut all_files = Vec::new();
1414
1415        let start_u64 = start.map(|s| s.as_u64());
1416        let end_u64 = end.map(|e| e.as_u64());
1417
1418        let base_dir = self.make_path(data_type, None)?;
1419
1420        // Use recursive listing to match Python's glob behavior
1421        let list_result = self.execute_async(async {
1422            let prefix = ObjectPath::from(format!("{base_dir}/"));
1423            let mut stream = self.object_store.list(Some(&prefix));
1424            let mut objects = Vec::new();
1425            while let Some(object) = stream.next().await {
1426                objects.push(object?);
1427            }
1428            Ok::<Vec<_>, anyhow::Error>(objects)
1429        })?;
1430
1431        for object in list_result {
1432            let path_str = object.location.to_string();
1433
1434            // Filter by identifiers if provided
1435            if let Some(ids) = identifiers {
1436                let path_components = extract_path_components(&path_str);
1437                let mut matches = false;
1438
1439                for id in ids {
1440                    if path_components.iter().any(|c| c.contains(id)) {
1441                        matches = true;
1442                        break;
1443                    }
1444                }
1445
1446                if !matches {
1447                    continue;
1448                }
1449            }
1450
1451            // Filter by timestamp range if filename can be parsed
1452            if path_str.ends_with(".parquet")
1453                && query_intersects_filename(&path_str, start_u64, end_u64)
1454            {
1455                all_files.push(path_str);
1456            }
1457        }
1458
1459        Ok(all_files)
1460    }
1461
1462    /// Helper method to reconstruct full URI for remote object store paths
1463    #[must_use]
1464    pub fn reconstruct_full_uri(&self, path_str: &str) -> String {
1465        if path_str.contains("://") {
1466            return path_str.to_string();
1467        }
1468
1469        // Check if this is a remote URI scheme that needs reconstruction
1470        if self.is_remote_uri() {
1471            let path = self.path_under_base(path_str);
1472            if let Ok(uri) = remote_full_uri(&self.original_uri, &path) {
1473                return uri;
1474            }
1475        }
1476
1477        // For local paths, extract the directory from the original URI
1478        if self.original_uri.starts_with("file://") {
1479            // Extract the path from the file:// URI
1480            if let Ok(url) = url::Url::parse(&self.original_uri)
1481                && let Ok(base_path) = url.to_file_path()
1482            {
1483                // Use platform-appropriate path separator for display
1484                // but object store paths always use forward slashes
1485                let base_str = base_path.to_string_lossy();
1486                return Self::join_paths(&base_str, path_str);
1487            }
1488        }
1489
1490        // For local paths without file:// prefix, use the original URI as base
1491        if self.base_path.is_empty() {
1492            // If base_path is empty and not a file URI, try using original_uri as base
1493            if self.original_uri.contains("://") {
1494                // Fallback: return the path as-is
1495                path_str.to_string()
1496            } else {
1497                Self::join_paths(self.original_uri.trim_end_matches('/'), path_str)
1498            }
1499        } else {
1500            let base = self.base_path.trim_end_matches('/');
1501            Self::join_paths(base, path_str)
1502        }
1503    }
1504
1505    /// Helper method to join paths using forward slashes (object store convention)
1506    #[must_use]
1507    fn join_paths(base: &str, path: &str) -> String {
1508        make_object_store_path(base, &[path])
1509    }
1510
1511    /// Resolves a path for use with DataFusion (avoiding Windows path doubling for file://).
1512    /// Returns the path as-is if it is already a full URI or absolute; otherwise builds
1513    /// file:// base + path for local catalogs or `reconstruct_full_uri` for remote.
1514    #[must_use]
1515    fn resolve_path_for_datafusion(&self, path: &str) -> String {
1516        if path.contains("://") {
1517            return path.to_string();
1518        }
1519
1520        if path.starts_with('/') {
1521            return path.to_string();
1522        }
1523
1524        if self.original_uri.starts_with("file://") {
1525            return append_path_to_file_uri(&self.original_uri, path);
1526        }
1527        self.reconstruct_full_uri(path)
1528    }
1529
1530    /// Like `resolve_path_for_datafusion` but ensures the result ends with a trailing slash.
1531    #[must_use]
1532    fn resolve_directory_for_datafusion(&self, directory: &str) -> String {
1533        let mut resolved = self.resolve_path_for_datafusion(directory);
1534        if !resolved.ends_with('/') {
1535            resolved.push('/');
1536        }
1537        resolved
1538    }
1539
1540    /// Returns the path string to push in `query_files` result list: relative for file://,
1541    /// full URI for remote (so callers can pass to `resolve_path_for_datafusion` later).
1542    #[must_use]
1543    fn path_for_query_list(&self, path: &str) -> String {
1544        if self.original_uri.starts_with("file://") {
1545            path.to_string()
1546        } else {
1547            self.reconstruct_full_uri(path)
1548        }
1549    }
1550
1551    /// Returns the native path string for the catalog root (for `std::fs`). Only valid when
1552    /// !`is_remote_uri()`; uses parquet's `file_uri_to_native_path` for file:// URIs.
1553    #[must_use]
1554    fn native_base_path_string(&self) -> String {
1555        if self.original_uri.starts_with("file://") {
1556            crate::parquet::file_uri_to_native_path(&self.original_uri)
1557        } else {
1558            self.original_uri.clone()
1559        }
1560    }
1561
1562    /// Helper method to check if the original URI uses a remote object store scheme
1563    #[must_use]
1564    pub fn is_remote_uri(&self) -> bool {
1565        self.original_uri
1566            .split_once("://")
1567            .is_some_and(|(scheme, _)| is_remote_uri_scheme(scheme))
1568    }
1569
1570    /// Executes a query against the catalog to retrieve market data of a specific type.
1571    ///
1572    /// This is the primary method for querying data from the catalog. It registers the appropriate
1573    /// object store with the DataFusion session, finds all relevant Parquet files, and executes
1574    /// the query across them. The method supports filtering by instrument IDs, time ranges, and
1575    /// custom SQL WHERE clauses.
1576    ///
1577    /// # Type Parameters
1578    ///
1579    /// - `T`: The data type to query, must implement required traits for deserialization and cataloging.
1580    ///
1581    /// # Parameters
1582    ///
1583    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings (e.g., "EUR/USD.SIM")
1584    ///   or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL"). If `None`, queries all identifiers.
1585    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1586    /// - `start`: Optional start timestamp for filtering (inclusive). If `None`, queries from the beginning.
1587    /// - `end`: Optional end timestamp for filtering (inclusive). If `None`, queries to the end.
1588    /// - `where_clause`: Optional SQL WHERE clause for additional filtering (e.g., "price > 100").
1589    /// - `files`: Optional list of specific files to query. If provided, skips file discovery.
1590    /// - `optimize_file_loading`: If `true` (default), registers entire directories with DataFusion,
1591    ///   which is more efficient for managing many files. If `false`, registers each file individually
1592    ///   (needed for operations like consolidation where precise file control is required).
1593    ///
1594    /// # Returns
1595    ///
1596    /// Returns a [`QueryResult`] containing the query execution context and data. It iterates
1597    /// results, so collect it into a `Result` to surface a stream or decode failure.
1598    ///
1599    /// # Errors
1600    ///
1601    /// Returns an error if:
1602    /// - Object store registration fails for remote URIs.
1603    /// - File discovery fails.
1604    /// - DataFusion query execution fails.
1605    /// - Data deserialization fails.
1606    ///
1607    /// # Performance Notes
1608    ///
1609    /// - Files are automatically filtered by timestamp ranges before querying.
1610    /// - DataFusion optimizes queries across multiple Parquet files.
1611    /// - Use specific instrument IDs and time ranges to improve performance.
1612    /// - WHERE clauses are pushed down to the Parquet reader when possible.
1613    /// - Directory-based registration (`optimize_file_loading=true`) is more efficient for queries
1614    ///   with many files, as it reduces the number of table registrations.
1615    ///
1616    /// # Examples
1617    ///
1618    /// ```rust,no_run
1619    /// use nautilus_core::UnixNanos;
1620    /// use nautilus_model::data::{Data, QuoteTick};
1621    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1622    ///
1623    /// let mut catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
1624    ///
1625    /// // Query all quote data (uses directory-based registration by default)
1626    /// let result = catalog.query::<QuoteTick>(None, None, None, None, None, true)?;
1627    /// let quotes: Vec<Data> = result.collect::<Result<_, _>>()?;
1628    ///
1629    /// // Query specific instruments within a time range
1630    /// let result = catalog.query::<QuoteTick>(
1631    ///     Some(vec!["EUR/USD.SIM".to_string(), "GBP/USD.SIM".to_string()]),
1632    ///     Some(UnixNanos::from(1609459200000000000)),
1633    ///     Some(UnixNanos::from(1609545600000000000)),
1634    ///     None,
1635    ///     None,
1636    ///     true,
1637    /// )?;
1638    ///
1639    /// // Query with custom WHERE clause and file-based registration
1640    /// let result = catalog.query::<QuoteTick>(
1641    ///     Some(vec!["EUR/USD.SIM".to_string()]),
1642    ///     None,
1643    ///     None,
1644    ///     Some("bid_price > 1.2000"),
1645    ///     None,
1646    ///     false, // Use file-based registration for precise control
1647    /// )?;
1648    /// # Ok::<(), anyhow::Error>(())
1649    /// ```
1650    pub fn query<T>(
1651        &mut self,
1652        identifiers: Option<Vec<String>>,
1653        start: Option<UnixNanos>,
1654        end: Option<UnixNanos>,
1655        where_clause: Option<&str>,
1656        files: Option<Vec<String>>,
1657        optimize_file_loading: bool,
1658    ) -> anyhow::Result<QueryResult>
1659    where
1660        T: DecodeDataFromRecordBatch + CatalogPathPrefix,
1661    {
1662        // Register the object store with the session for remote URIs only.
1663        // For local file:// we do not register: we pass full file URLs to register_parquet
1664        // so DataFusion's default file provider handles them (avoids path doubling on Windows
1665        // where a registered store would receive a path that gets prefixed again).
1666        self.register_remote_object_store()?;
1667
1668        let files_list = if let Some(files) = files {
1669            files
1670        } else {
1671            self.query_files(T::path_prefix(), identifiers, start, end)?
1672        };
1673
1674        if optimize_file_loading {
1675            // Use directory-based registration for efficiency. DataFusion handles
1676            // reading all files in each directory, which is more memory-efficient
1677            // than registering many individual file tables.
1678            let directories: IndexSet<String> = files_list
1679                .iter()
1680                .filter_map(|file_uri| {
1681                    // Extract directory path (everything except the filename)
1682                    let path = Path::new(file_uri);
1683                    path.parent().map(|p| p.to_string_lossy().to_string())
1684                })
1685                .collect();
1686
1687            for directory in directories {
1688                // Extract identifier from directory path (last component)
1689                let path_parts: Vec<&str> = directory.split('/').collect();
1690                let identifier = if path_parts.is_empty() {
1691                    "unknown".to_string()
1692                } else {
1693                    path_parts[path_parts.len() - 1].to_string()
1694                };
1695                let safe_sql_identifier = make_sql_safe_identifier(&identifier);
1696
1697                // Create table name from path_prefix and identifier (no filename component)
1698                let table_name = format!("{}_{}", T::path_prefix(), safe_sql_identifier);
1699                let query = build_query(&table_name, start, end, where_clause);
1700
1701                let resolved_path = self.resolve_directory_for_datafusion(&directory);
1702
1703                self.session
1704                    .add_file::<T>(&table_name, &resolved_path, Some(&query), None)?;
1705            }
1706        } else {
1707            // Register files individually (for operations requiring precise file control)
1708            for file_uri in &files_list {
1709                // Extract identifier from file path and filename to create meaningful table names
1710                let identifier = extract_identifier_from_path(file_uri);
1711                let safe_sql_identifier = make_sql_safe_identifier(&identifier);
1712                let safe_filename = extract_sql_safe_filename(file_uri);
1713
1714                // Create table name from path_prefix, identifier, and filename
1715                let table_name = format!(
1716                    "{}_{}_{}",
1717                    T::path_prefix(),
1718                    safe_sql_identifier,
1719                    safe_filename
1720                );
1721                let query = build_query(&table_name, start, end, where_clause);
1722
1723                let resolved_path = self.resolve_path_for_datafusion(file_uri);
1724                self.session
1725                    .add_file::<T>(&table_name, &resolved_path, Some(&query), None)?;
1726            }
1727        }
1728
1729        Ok(self.session.get_query_result())
1730    }
1731
1732    /// Queries typed data from the catalog and returns results as a strongly-typed vector.
1733    ///
1734    /// This is a convenience method that wraps the generic `query` method and automatically
1735    /// collects and converts the results into a vector of the specific data type. It handles
1736    /// the type conversion from the generic [`Data`] enum to the concrete type `T`.
1737    ///
1738    /// # Type Parameters
1739    ///
1740    /// - `T`: The specific data type to query and return. Must implement required traits for
1741    ///   deserialization, cataloging, and conversion from the [`Data`] enum.
1742    ///
1743    /// # Parameters
1744    ///
1745    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings (e.g., "EUR/USD.SIM")
1746    ///   or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL"). If `None`, queries all identifiers.
1747    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1748    /// - `start`: Optional start timestamp for filtering (inclusive). If `None`, queries from the beginning.
1749    /// - `end`: Optional end timestamp for filtering (inclusive). If `None`, queries to the end.
1750    /// - `where_clause`: Optional SQL WHERE clause for additional filtering. Use standard SQL syntax
1751    ///   with column names matching the Parquet schema (e.g., "`bid_price` > 1.2000", "volume > 1000").
1752    ///
1753    /// # Returns
1754    ///
1755    /// Returns a vector of the specific data type `T`, sorted by timestamp. The vector will be
1756    /// empty if no data matches the query criteria.
1757    ///
1758    /// # Errors
1759    ///
1760    /// Returns an error if:
1761    /// - The underlying query execution fails.
1762    /// - Data type conversion fails.
1763    /// - Object store access fails.
1764    /// - Invalid WHERE clause syntax is provided.
1765    ///
1766    /// # Performance Considerations
1767    ///
1768    /// - Use specific instrument IDs and time ranges to minimize data scanning.
1769    /// - WHERE clauses are pushed down to Parquet readers when possible.
1770    /// - Results are automatically sorted by timestamp during collection.
1771    /// - Memory usage scales with the amount of data returned.
1772    ///
1773    /// # Examples
1774    ///
1775    /// ```rust,no_run
1776    /// use nautilus_core::UnixNanos;
1777    /// use nautilus_model::data::{Bar, QuoteTick, TradeTick};
1778    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
1779    ///
1780    /// let mut catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
1781    ///
1782    /// // Query all quotes for a specific instrument
1783    /// let quotes: Vec<QuoteTick> = catalog.query_typed_data(
1784    ///     Some(vec!["EUR/USD.SIM".to_string()]),
1785    ///     None,
1786    ///     None,
1787    ///     None,
1788    ///     None,
1789    ///     true,
1790    /// )?;
1791    ///
1792    /// // Query trades within a specific time range
1793    /// let trades: Vec<TradeTick> = catalog.query_typed_data(
1794    ///     Some(vec!["BTC/USD.SIM".to_string()]),
1795    ///     Some(UnixNanos::from(1609459200000000000)),
1796    ///     Some(UnixNanos::from(1609545600000000000)),
1797    ///     None,
1798    ///     None,
1799    ///     true,
1800    /// )?;
1801    ///
1802    /// // Query bars with volume filter (using instrument_id - partial match for bar_type)
1803    /// let bars: Vec<Bar> = catalog.query_typed_data(
1804    ///     Some(vec!["AAPL.NASDAQ".to_string()]),
1805    ///     None,
1806    ///     None,
1807    ///     Some("volume > 1000000"),
1808    ///     None,
1809    ///     true,
1810    /// )?;
1811    ///
1812    /// // Query bars with specific bar_type
1813    /// let bars: Vec<Bar> = catalog.query_typed_data(
1814    ///     Some(vec!["AAPL.NASDAQ-1-MINUTE-LAST-EXTERNAL".to_string()]),
1815    ///     None,
1816    ///     None,
1817    ///     None,
1818    ///     None,
1819    ///     true,
1820    /// )?;
1821    ///
1822    /// // Query multiple instruments with price filter
1823    /// let quotes: Vec<QuoteTick> = catalog.query_typed_data(
1824    ///     Some(vec!["EUR/USD.SIM".to_string(), "GBP/USD.SIM".to_string()]),
1825    ///     None,
1826    ///     None,
1827    ///     Some("bid_price > 1.2000 AND ask_price < 1.3000"),
1828    ///     None,
1829    ///     true,
1830    /// )?;
1831    /// # Ok::<(), anyhow::Error>(())
1832    /// ```
1833    pub fn query_typed_data<T>(
1834        &mut self,
1835        identifiers: Option<Vec<String>>,
1836        start: Option<UnixNanos>,
1837        end: Option<UnixNanos>,
1838        where_clause: Option<&str>,
1839        files: Option<Vec<String>>,
1840        optimize_file_loading: bool,
1841    ) -> anyhow::Result<Vec<T>>
1842    where
1843        T: DecodeDataFromRecordBatch + CatalogPathPrefix + TryFrom<Data>,
1844    {
1845        // Reset session to allow repeated queries (streams are consumed on each query)
1846        self.reset_session();
1847
1848        let query_result = self.query::<T>(
1849            identifiers,
1850            start,
1851            end,
1852            where_clause,
1853            files,
1854            optimize_file_loading,
1855        )?;
1856        let all_data = query_result.collect::<Result<Vec<_>, _>>()?;
1857
1858        // Convert Data enum variants to specific type T using to_variant
1859        Ok(to_variant::<T>(all_data))
1860    }
1861
1862    /// Queries typed records that are not represented by the [`Data`] enum.
1863    ///
1864    /// # Errors
1865    ///
1866    /// Returns an error if object store registration, file discovery, query execution,
1867    /// or record decoding fails.
1868    pub fn query_typed<T>(
1869        &mut self,
1870        identifiers: Option<Vec<String>>,
1871        start: Option<UnixNanos>,
1872        end: Option<UnixNanos>,
1873        where_clause: Option<&str>,
1874        files: Option<Vec<String>>,
1875        optimize_file_loading: bool,
1876    ) -> anyhow::Result<Vec<T>>
1877    where
1878        T: DecodeTypedFromRecordBatch + CatalogPathPrefix + HasTsInit,
1879    {
1880        self.reset_session();
1881
1882        self.register_remote_object_store()?;
1883
1884        let files_list = if let Some(files) = files {
1885            files
1886        } else {
1887            self.query_files(T::path_prefix(), identifiers, start, end)?
1888        };
1889
1890        let mut all_records = Vec::new();
1891
1892        if optimize_file_loading {
1893            let directories: IndexSet<String> = files_list
1894                .iter()
1895                .filter_map(|file_uri| {
1896                    Path::new(file_uri)
1897                        .parent()
1898                        .map(|path| path.to_string_lossy().to_string())
1899                })
1900                .collect();
1901
1902            for directory in directories {
1903                let path_parts: Vec<&str> = directory.split('/').collect();
1904                let identifier = if path_parts.is_empty() {
1905                    "unknown".to_string()
1906                } else {
1907                    path_parts[path_parts.len() - 1].to_string()
1908                };
1909                let safe_sql_identifier = make_sql_safe_identifier(&identifier);
1910                let table_name = format!("{}_{}", T::path_prefix(), safe_sql_identifier);
1911                let query = build_query(&table_name, start, end, where_clause);
1912                let resolved_path = self.resolve_directory_for_datafusion(&directory);
1913                let batches = self.session.collect_query_batches(
1914                    &table_name,
1915                    &resolved_path,
1916                    Some(&query),
1917                )?;
1918
1919                all_records.extend(Self::convert_record_batches_to_typed::<T>(batches)?);
1920            }
1921        } else {
1922            for file_uri in &files_list {
1923                let identifier = extract_identifier_from_path(file_uri);
1924                let safe_sql_identifier = make_sql_safe_identifier(&identifier);
1925                let safe_filename = extract_sql_safe_filename(file_uri);
1926                let table_name = format!(
1927                    "{}_{}_{}",
1928                    T::path_prefix(),
1929                    safe_sql_identifier,
1930                    safe_filename
1931                );
1932                let query = build_query(&table_name, start, end, where_clause);
1933                let resolved_path = self.resolve_path_for_datafusion(file_uri);
1934                let batches = self.session.collect_query_batches(
1935                    &table_name,
1936                    &resolved_path,
1937                    Some(&query),
1938                )?;
1939
1940                all_records.extend(Self::convert_record_batches_to_typed::<T>(batches)?);
1941            }
1942        }
1943
1944        if !is_monotonically_increasing_by_init(&all_records) {
1945            all_records.sort_by_key(HasTsInit::ts_init);
1946        }
1947
1948        Ok(all_records)
1949    }
1950
1951    /// Queries custom data dynamically by type name.
1952    ///
1953    /// This method allows querying custom data types without compile-time knowledge of the type.
1954    /// It uses dynamic schema decoding based on the type name stored in metadata.
1955    ///
1956    /// # Parameters
1957    ///
1958    /// - `type_name`: The name of the custom data type to query.
1959    /// - `identifiers`: Optional list of instrument identifiers to filter by.
1960    /// - `start`: Optional start timestamp for filtering.
1961    /// - `end`: Optional end timestamp for filtering.
1962    /// - `where_clause`: Optional SQL WHERE clause for additional filtering.
1963    /// - `files`: Optional list of specific files to query.
1964    /// - `_optimize_file_loading`: Whether to optimize file loading (currently unused).
1965    ///
1966    /// # Returns
1967    ///
1968    /// Returns a vector of `Data` enum variants containing the custom data.
1969    ///
1970    /// # Errors
1971    ///
1972    /// Returns an error if:
1973    /// - File discovery fails.
1974    /// - Data decoding fails.
1975    /// - Query execution fails.
1976    #[expect(clippy::too_many_arguments)]
1977    pub fn query_custom_data_dynamic(
1978        &mut self,
1979        type_name: &str,
1980        identifiers: Option<&[String]>,
1981        start: Option<UnixNanos>,
1982        end: Option<UnixNanos>,
1983        where_clause: Option<&str>,
1984        files: Option<Vec<String>>,
1985        _optimize_file_loading: bool,
1986    ) -> anyhow::Result<Vec<Data>> {
1987        self.reset_session();
1988
1989        self.register_remote_object_store()?;
1990
1991        let path_prefix = format!("custom/{type_name}");
1992
1993        let files = if let Some(f) = files {
1994            f.into_iter()
1995                .map(|p| self.to_object_path(&p).map(|op| op.to_string()))
1996                .collect::<anyhow::Result<Vec<_>>>()?
1997        } else {
1998            self.list_parquet_files_with_criteria(&path_prefix, identifiers, start, end)?
1999        };
2000
2001        if files.is_empty() {
2002            return Ok(Vec::new());
2003        }
2004
2005        // Use CustomDataDecoder for all custom data. Pass type_name so decode can look up
2006        // the type when Parquet/DataFusion does not preserve schema metadata. Callers must
2007        // ensure Rust custom types are registered via ensure_custom_data_registered::<T>().
2008        let mut lookup_metadata = HashMap::new();
2009        lookup_metadata.insert("type_name".to_string(), type_name.to_string());
2010        let registered_schema = CustomDataDecoder::get_schema(Some(lookup_metadata));
2011        registered_schema.field_with_name("ts_init").map_err(|_| {
2012            anyhow::anyhow!(
2013                "custom data type '{type_name}' is not registered with an Arrow schema containing ts_init; \
2014                 call ensure_custom_data_registered::<T>() before querying"
2015            )
2016        })?;
2017
2018        for file in files {
2019            let identifier = extract_identifier_from_path(&file);
2020            let safe_type_name = make_sql_safe_identifier(type_name);
2021            let safe_sql_identifier = make_sql_safe_identifier(&identifier);
2022            let safe_filename = extract_sql_safe_filename(&file);
2023            let table_name =
2024                format!("custom_{safe_type_name}_{safe_sql_identifier}_{safe_filename}");
2025            let resolved_path = self.resolve_path_for_datafusion(&file);
2026            let sql_query = build_query(&table_name, start, end, where_clause);
2027
2028            // Use schemaless registration so DataFusion preserves the parquet file's
2029            // schema metadata (e.g. `bar_type`) on output batches, since the
2030            // explicit-schema variant strips per-batch metadata that decoders rely on.
2031            self.session
2032                .add_file::<CustomDataDecoder>(
2033                    &table_name,
2034                    &resolved_path,
2035                    Some(&sql_query),
2036                    Some(type_name),
2037                )
2038                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
2039        }
2040
2041        let query_result = self.session.get_query_result();
2042        Ok(query_result.collect::<Result<Vec<_>, _>>()?)
2043    }
2044
2045    /// Queries all Parquet files for a specific data type and optional instrument IDs.
2046    ///
2047    /// This method finds all Parquet files that match the specified criteria and returns
2048    /// their full URIs. The files are filtered by data type, instrument IDs (if provided),
2049    /// and timestamp range (if provided).
2050    ///
2051    /// # Parameters
2052    ///
2053    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
2054    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
2055    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
2056    ///   For bars, partial matching is supported.
2057    /// - `start`: Optional start timestamp to filter files by their time range.
2058    /// - `end`: Optional end timestamp to filter files by their time range.
2059    ///
2060    /// # Returns
2061    ///
2062    /// Returns a vector of file URI strings that match the query criteria,
2063    /// or an error if the query fails.
2064    ///
2065    /// # Errors
2066    ///
2067    /// Returns an error if:
2068    /// - The directory path cannot be constructed.
2069    /// - Object store listing operations fail.
2070    /// - URI reconstruction fails.
2071    ///
2072    /// # Examples
2073    ///
2074    /// ```rust,no_run
2075    /// use nautilus_core::UnixNanos;
2076    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2077    ///
2078    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2079    ///
2080    /// // Query all quote files
2081    /// let files = catalog.query_files("quotes", None, None, None)?;
2082    ///
2083    /// // Query trade files for specific instruments within a time range
2084    /// let files = catalog.query_files(
2085    ///     "trades",
2086    ///     Some(vec!["BTC/USD.SIM".to_string(), "ETH/USD.SIM".to_string()]),
2087    ///     Some(UnixNanos::from(1609459200000000000)),
2088    ///     Some(UnixNanos::from(1609545600000000000)),
2089    /// )?;
2090    /// # Ok::<(), anyhow::Error>(())
2091    /// ```
2092    pub fn query_files(
2093        &self,
2094        data_cls: &str,
2095        identifiers: Option<Vec<String>>,
2096        start: Option<UnixNanos>,
2097        end: Option<UnixNanos>,
2098    ) -> anyhow::Result<Vec<String>> {
2099        let mut files = Vec::new();
2100
2101        let start_u64 = start.map(|s| s.as_u64());
2102        let end_u64 = end.map(|e| e.as_u64());
2103
2104        let base_dir = self.make_path(data_cls, None)?;
2105
2106        // Use recursive listing to match Python's glob behavior
2107        let list_result = self.execute_async(async {
2108            let prefix = ObjectPath::from(format!("{base_dir}/"));
2109            let mut stream = self.object_store.list(Some(&prefix));
2110            let mut objects = Vec::new();
2111            while let Some(object) = stream.next().await {
2112                objects.push(object?);
2113            }
2114            Ok::<Vec<_>, anyhow::Error>(objects)
2115        })?;
2116
2117        let mut file_paths: Vec<String> = list_result
2118            .into_iter()
2119            .filter_map(|object| {
2120                let path_str = object.location.to_string();
2121                if path_str.ends_with(".parquet") {
2122                    Some(path_str)
2123                } else {
2124                    None
2125                }
2126            })
2127            .collect();
2128        file_paths.sort();
2129
2130        // Apply identifier filtering if provided
2131        if let Some(identifiers) = identifiers {
2132            let safe_identifiers: Vec<String> = identifiers
2133                .iter()
2134                .map(|id| urisafe_instrument_id(id))
2135                .collect();
2136
2137            // Exact match by default for instrument_ids or bar_types
2138            let exact_match_file_paths: Vec<String> = file_paths
2139                .iter()
2140                .filter(|file_path| {
2141                    // Extract the directory name (second to last path component)
2142                    let path_parts: Vec<&str> = file_path.split('/').collect();
2143                    if path_parts.len() >= 2 {
2144                        let dir_name =
2145                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
2146                        safe_identifiers.contains(&dir_name)
2147                    } else {
2148                        false
2149                    }
2150                })
2151                .cloned()
2152                .collect();
2153
2154            if exact_match_file_paths.is_empty() && data_cls == "bars" {
2155                file_paths.retain(|file_path| {
2156                    let path_parts: Vec<&str> = file_path.split('/').collect();
2157                    if path_parts.len() >= 2 {
2158                        let dir_name =
2159                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
2160
2161                        if let Some(bar_instrument_id) = extract_bar_type_instrument_id(&dir_name) {
2162                            safe_identifiers.iter().any(|id| id == bar_instrument_id)
2163                        } else {
2164                            false
2165                        }
2166                    } else {
2167                        false
2168                    }
2169                });
2170            } else {
2171                file_paths = exact_match_file_paths;
2172            }
2173        }
2174
2175        // Apply timestamp filtering
2176        file_paths.retain(|file_path| query_intersects_filename(file_path, start_u64, end_u64));
2177
2178        for file_path in file_paths {
2179            files.push(self.path_for_query_list(&file_path));
2180        }
2181
2182        Ok(files)
2183    }
2184
2185    /// Queries quote tick data for the specified instrument(s) and time range.
2186    ///
2187    /// # Errors
2188    ///
2189    /// Returns an error if file discovery, query execution, or decoding fails.
2190    pub fn quote_ticks(
2191        &mut self,
2192        instrument_ids: Option<Vec<String>>,
2193        start: Option<UnixNanos>,
2194        end: Option<UnixNanos>,
2195    ) -> anyhow::Result<Vec<QuoteTick>> {
2196        self.query_typed_data::<QuoteTick>(instrument_ids, start, end, None, None, true)
2197    }
2198
2199    /// Queries trade tick data for the specified instrument(s) and time range.
2200    ///
2201    /// # Errors
2202    ///
2203    /// Returns an error if file discovery, query execution, or decoding fails.
2204    pub fn trade_ticks(
2205        &mut self,
2206        instrument_ids: Option<Vec<String>>,
2207        start: Option<UnixNanos>,
2208        end: Option<UnixNanos>,
2209    ) -> anyhow::Result<Vec<TradeTick>> {
2210        self.query_typed_data::<TradeTick>(instrument_ids, start, end, None, None, true)
2211    }
2212
2213    /// Queries bar data for the specified instrument(s) and time range.
2214    ///
2215    /// # Errors
2216    ///
2217    /// Returns an error if file discovery, query execution, or decoding fails.
2218    pub fn bars(
2219        &mut self,
2220        instrument_ids: Option<Vec<String>>,
2221        start: Option<UnixNanos>,
2222        end: Option<UnixNanos>,
2223    ) -> anyhow::Result<Vec<Bar>> {
2224        self.query_typed_data::<Bar>(instrument_ids, start, end, None, None, true)
2225    }
2226
2227    /// Queries order book delta data for the specified instrument(s) and time range.
2228    ///
2229    /// # Errors
2230    ///
2231    /// Returns an error if file discovery, query execution, or decoding fails.
2232    pub fn order_book_deltas(
2233        &mut self,
2234        instrument_ids: Option<Vec<String>>,
2235        start: Option<UnixNanos>,
2236        end: Option<UnixNanos>,
2237    ) -> anyhow::Result<Vec<OrderBookDelta>> {
2238        self.query_typed_data::<OrderBookDelta>(instrument_ids, start, end, None, None, true)
2239    }
2240
2241    /// Queries order book depth L10 data for the specified instrument(s) and time range.
2242    ///
2243    /// # Errors
2244    ///
2245    /// Returns an error if file discovery, query execution, or decoding fails.
2246    pub fn order_book_depth10(
2247        &mut self,
2248        instrument_ids: Option<Vec<String>>,
2249        start: Option<UnixNanos>,
2250        end: Option<UnixNanos>,
2251    ) -> anyhow::Result<Vec<OrderBookDepth10>> {
2252        self.query_typed_data::<OrderBookDepth10>(instrument_ids, start, end, None, None, true)
2253    }
2254
2255    /// Queries funding rate updates for the specified instrument(s) and time range.
2256    ///
2257    /// # Errors
2258    ///
2259    /// Returns an error if file discovery, query execution, or decoding fails.
2260    pub fn funding_rates(
2261        &mut self,
2262        instrument_ids: Option<Vec<String>>,
2263        start: Option<UnixNanos>,
2264        end: Option<UnixNanos>,
2265    ) -> anyhow::Result<Vec<FundingRateUpdate>> {
2266        self.query_typed::<FundingRateUpdate>(instrument_ids, start, end, None, None, true)
2267    }
2268
2269    /// Queries option greeks data for the specified instrument(s) and time range.
2270    ///
2271    /// # Errors
2272    ///
2273    /// Returns an error if file discovery, query execution, or decoding fails.
2274    pub fn option_greeks(
2275        &mut self,
2276        instrument_ids: Option<Vec<String>>,
2277        start: Option<UnixNanos>,
2278        end: Option<UnixNanos>,
2279    ) -> anyhow::Result<Vec<OptionGreeks>> {
2280        self.query_typed_data::<OptionGreeks>(instrument_ids, start, end, None, None, true)
2281    }
2282
2283    /// Queries instrument close data for the specified instrument(s) and time range.
2284    ///
2285    /// # Errors
2286    ///
2287    /// Returns an error if file discovery, query execution, or decoding fails.
2288    pub fn instrument_closes(
2289        &mut self,
2290        instrument_ids: Option<Vec<String>>,
2291        start: Option<UnixNanos>,
2292        end: Option<UnixNanos>,
2293    ) -> anyhow::Result<Vec<InstrumentClose>> {
2294        self.query_typed_data::<InstrumentClose>(instrument_ids, start, end, None, None, true)
2295    }
2296
2297    /// Queries any instrument data for the specified instrument(s) and time range.
2298    ///
2299    /// # Errors
2300    ///
2301    /// Returns an error if file discovery, query execution, or instrument decoding fails.
2302    pub fn instruments(
2303        &self,
2304        instrument_ids: Option<&[String]>,
2305        start: Option<UnixNanos>,
2306        end: Option<UnixNanos>,
2307    ) -> anyhow::Result<Vec<InstrumentAny>> {
2308        self.query_instruments_filtered(instrument_ids, start, end)
2309    }
2310
2311    /// Retrieves a list of file paths for a given data type.
2312    ///
2313    /// This method constructs a path pattern to find all parquet files
2314    /// associated with the specified data type in the catalog's directory structure.
2315    ///
2316    /// # Parameters
2317    ///
2318    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades", "bars").
2319    ///
2320    /// # Returns
2321    ///
2322    /// Returns a vector of file paths matching the data type, or an error if the operation fails.
2323    ///
2324    /// # Errors
2325    ///
2326    /// Returns an error if:
2327    /// - Object store listing operations fail.
2328    /// - Directory access is denied.
2329    ///
2330    /// # Examples
2331    ///
2332    /// ```rust,no_run
2333    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2334    ///
2335    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2336    /// let files = catalog.get_file_list_from_data_cls("quotes")?;
2337    ///
2338    /// for file in files {
2339    ///     println!("Found file: {}", file);
2340    /// }
2341    /// # Ok::<(), anyhow::Error>(())
2342    /// ```
2343    pub fn get_file_list_from_data_cls(&self, data_cls: &str) -> anyhow::Result<Vec<String>> {
2344        let base_dir = self.make_path(data_cls, None)?;
2345
2346        let list_result = self.execute_async(async {
2347            let prefix = ObjectPath::from(format!("{base_dir}/"));
2348            let mut stream = self.object_store.list(Some(&prefix));
2349            let mut objects = Vec::new();
2350            while let Some(object) = stream.next().await {
2351                objects.push(object?);
2352            }
2353            Ok::<Vec<_>, anyhow::Error>(objects)
2354        })?;
2355
2356        let file_paths: Vec<String> = list_result
2357            .into_iter()
2358            .filter_map(|object| {
2359                let path_str = object.location.to_string();
2360                if path_str.ends_with(".parquet") {
2361                    Some(path_str)
2362                } else {
2363                    None
2364                }
2365            })
2366            .collect();
2367
2368        Ok(file_paths)
2369    }
2370
2371    /// Filters a list of file paths based on identifiers and time range.
2372    ///
2373    /// This method filters the provided file paths by:
2374    /// 1. Matching identifiers (exact match for instruments, prefix match for bars)
2375    /// 2. Intersecting with the specified time range
2376    ///
2377    /// # Parameters
2378    ///
2379    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades", "bars").
2380    /// - `file_paths`: List of file paths to filter.
2381    /// - `identifiers`: Optional list of identifiers to match against file paths.
2382    /// - `start`: Optional start timestamp for filtering.
2383    /// - `end`: Optional end timestamp for filtering.
2384    ///
2385    /// # Returns
2386    ///
2387    /// Returns a filtered vector of file paths that match the criteria.
2388    ///
2389    /// # Notes
2390    ///
2391    /// For Bar data types, if exact identifier matching fails, the function attempts
2392    /// partial matching by checking if the file's identifier starts with the provided identifier
2393    /// followed by a dash (to match bar type patterns).
2394    ///
2395    /// # Errors
2396    ///
2397    /// Returns an error if identifier filtering needs bar-type fallback and path
2398    /// resolution fails.
2399    ///
2400    /// # Examples
2401    ///
2402    /// ```rust,no_run
2403    /// use nautilus_core::UnixNanos;
2404    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2405    ///
2406    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2407    /// let all_files = catalog.get_file_list_from_data_cls("quotes")?;
2408    ///
2409    /// let filtered = catalog.filter_files(
2410    ///     "quotes",
2411    ///     all_files,
2412    ///     Some(vec!["EUR/USD.SIM".to_string()]),
2413    ///     Some(UnixNanos::from(1609459200000000000)),
2414    ///     Some(UnixNanos::from(1609545600000000000)),
2415    /// )?;
2416    /// # Ok::<(), anyhow::Error>(())
2417    /// ```
2418    pub fn filter_files(
2419        &self,
2420        data_cls: &str,
2421        file_paths: Vec<String>,
2422        identifiers: Option<Vec<String>>,
2423        start: Option<UnixNanos>,
2424        end: Option<UnixNanos>,
2425    ) -> anyhow::Result<Vec<String>> {
2426        let mut filtered_paths = file_paths;
2427
2428        // Apply identifier filtering if provided
2429        if let Some(identifiers) = identifiers {
2430            let safe_identifiers: Vec<String> = identifiers
2431                .iter()
2432                .map(|id| urisafe_instrument_id(id))
2433                .collect();
2434
2435            // Extract directory names from file paths
2436            let file_safe_identifiers: Vec<String> = filtered_paths
2437                .iter()
2438                .map(|file_path| {
2439                    let path_parts: Vec<&str> = file_path.split('/').collect();
2440                    if path_parts.len() >= 2 {
2441                        decode_object_store_segment(path_parts[path_parts.len() - 2])
2442                    } else {
2443                        String::new()
2444                    }
2445                })
2446                .collect();
2447
2448            // Exact match by default for instrument_ids or bar_types
2449            let exact_match_file_paths: Vec<String> = filtered_paths
2450                .iter()
2451                .enumerate()
2452                .filter_map(|(i, file_path)| {
2453                    let dir_name = &file_safe_identifiers[i];
2454                    if safe_identifiers.iter().any(|safe_id| safe_id == dir_name) {
2455                        Some(file_path.clone())
2456                    } else {
2457                        None
2458                    }
2459                })
2460                .collect();
2461
2462            if exact_match_file_paths.is_empty() && data_cls == "bars" {
2463                // Partial match of instrument_ids in bar_types for bars
2464                filtered_paths.retain(|file_path| {
2465                    let path_parts: Vec<&str> = file_path.split('/').collect();
2466                    if path_parts.len() >= 2 {
2467                        let dir_name =
2468                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
2469                        safe_identifiers
2470                            .iter()
2471                            .any(|safe_id| dir_name.starts_with(&format!("{safe_id}-")))
2472                    } else {
2473                        false
2474                    }
2475                });
2476            } else {
2477                filtered_paths = exact_match_file_paths;
2478            }
2479        }
2480
2481        // Apply timestamp filtering
2482        let start_u64 = start.map(|s| s.as_u64());
2483        let end_u64 = end.map(|e| e.as_u64());
2484        filtered_paths.retain(|file_path| query_intersects_filename(file_path, start_u64, end_u64));
2485
2486        Ok(filtered_paths)
2487    }
2488
2489    /// Finds the missing time intervals for a specific data type and instrument ID.
2490    ///
2491    /// This method compares a requested time range against the existing data coverage
2492    /// and returns the gaps that need to be filled. This is useful for determining
2493    /// what data needs to be fetched or backfilled.
2494    ///
2495    /// # Parameters
2496    ///
2497    /// - `start`: Start timestamp of the requested range (Unix nanoseconds).
2498    /// - `end`: End timestamp of the requested range (Unix nanoseconds).
2499    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
2500    /// - `instrument_id`: Optional instrument ID to target a specific instrument's data.
2501    ///
2502    /// # Returns
2503    ///
2504    /// Returns a vector of (start, end) tuples representing the missing intervals,
2505    /// or an error if the operation fails.
2506    ///
2507    /// # Errors
2508    ///
2509    /// Returns an error if:
2510    /// - The directory path cannot be constructed.
2511    /// - Interval retrieval fails.
2512    /// - Gap calculation fails.
2513    ///
2514    /// # Examples
2515    ///
2516    /// ```rust,no_run
2517    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2518    ///
2519    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2520    ///
2521    /// // Find missing intervals for quote data
2522    /// let missing = catalog.get_missing_intervals_for_request(
2523    ///     1609459200000000000, // start
2524    ///     1609545600000000000, // end
2525    ///     "quotes",
2526    ///     Some("BTCUSD"),
2527    /// )?;
2528    ///
2529    /// for (start, end) in missing {
2530    ///     println!("Missing data from {} to {}", start, end);
2531    /// }
2532    /// # Ok::<(), anyhow::Error>(())
2533    /// ```
2534    pub fn get_missing_intervals_for_request(
2535        &self,
2536        start: u64,
2537        end: u64,
2538        data_cls: &str,
2539        identifier: Option<&str>,
2540    ) -> anyhow::Result<Vec<(u64, u64)>> {
2541        let intervals = self.get_intervals(data_cls, identifier)?;
2542
2543        Ok(query_interval_diff(start, end, &intervals))
2544    }
2545
2546    /// Gets the first (earliest) timestamp for a specific data type and identifier.
2547    ///
2548    /// This method finds the earliest timestamp covered by existing data files for
2549    /// the specified data type and identifier. This is useful for determining
2550    /// the oldest data available or for incremental data updates.
2551    ///
2552    /// # Parameters
2553    ///
2554    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
2555    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
2556    ///
2557    /// # Returns
2558    ///
2559    /// Returns `Some(timestamp)` if data exists, `None` if no data is found,
2560    /// or an error if the operation fails.
2561    ///
2562    /// # Errors
2563    ///
2564    /// Returns an error if:
2565    /// - The directory path cannot be constructed.
2566    /// - Interval retrieval fails.
2567    ///
2568    /// # Note
2569    ///
2570    /// Unlike the Python implementation, this method does not check subclasses of the
2571    /// data type. The Python version checks `[data_cls, *data_cls.__subclasses__()]` to
2572    /// handle cases where subclasses might use different directory names. Since Rust
2573    /// works with string names rather than types, subclass checking is not possible.
2574    /// In practice, most subclasses map to the same directory name via `class_to_filename`,
2575    /// so this difference is typically not significant.
2576    ///
2577    /// # Examples
2578    ///
2579    /// ```rust,no_run
2580    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2581    ///
2582    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2583    ///
2584    /// // Get the first timestamp for quote data
2585    /// if let Some(first_ts) = catalog.query_first_timestamp("quotes", Some("BTCUSD"))? {
2586    ///     println!("First quote timestamp: {}", first_ts);
2587    /// } else {
2588    ///     println!("No quote data found");
2589    /// }
2590    /// # Ok::<(), anyhow::Error>(())
2591    /// ```
2592    pub fn query_first_timestamp(
2593        &self,
2594        data_cls: &str,
2595        identifier: Option<&str>,
2596    ) -> anyhow::Result<Option<u64>> {
2597        let intervals = self.get_intervals(data_cls, identifier)?;
2598
2599        if intervals.is_empty() {
2600            return Ok(None);
2601        }
2602
2603        Ok(intervals.first().map(|interval| interval.0))
2604    }
2605
2606    /// Gets the last (most recent) timestamp for a specific data type and identifier.
2607    ///
2608    /// This method finds the latest timestamp covered by existing data files for
2609    /// the specified data type and identifier. This is useful for determining
2610    /// the most recent data available or for incremental data updates.
2611    ///
2612    /// # Parameters
2613    ///
2614    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
2615    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
2616    ///
2617    /// # Returns
2618    ///
2619    /// Returns `Some(timestamp)` if data exists, `None` if no data is found,
2620    /// or an error if the operation fails.
2621    ///
2622    /// # Errors
2623    ///
2624    /// Returns an error if:
2625    /// - The directory path cannot be constructed.
2626    /// - Interval retrieval fails.
2627    ///
2628    /// # Note
2629    ///
2630    /// Unlike the Python implementation, this method does not check subclasses of the
2631    /// data type. The Python version checks `[data_cls, *data_cls.__subclasses__()]` to
2632    /// handle cases where subclasses might use different directory names. Since Rust
2633    /// works with string names rather than types, subclass checking is not possible.
2634    /// In practice, most subclasses map to the same directory name via `class_to_filename`,
2635    /// so this difference is typically not significant.
2636    ///
2637    /// # Examples
2638    ///
2639    /// ```rust,no_run
2640    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2641    ///
2642    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2643    ///
2644    /// // Get the last timestamp for quote data
2645    /// if let Some(last_ts) = catalog.query_last_timestamp("quotes", Some("BTCUSD"))? {
2646    ///     println!("Last quote timestamp: {}", last_ts);
2647    /// } else {
2648    ///     println!("No quote data found");
2649    /// }
2650    /// # Ok::<(), anyhow::Error>(())
2651    /// ```
2652    pub fn query_last_timestamp(
2653        &self,
2654        data_cls: &str,
2655        identifier: Option<&str>,
2656    ) -> anyhow::Result<Option<u64>> {
2657        let intervals = self.get_intervals(data_cls, identifier)?;
2658
2659        if intervals.is_empty() {
2660            return Ok(None);
2661        }
2662
2663        Ok(intervals.last().map(|interval| interval.1))
2664    }
2665
2666    /// Gets the time intervals covered by Parquet files for a specific data type and identifier.
2667    ///
2668    /// This method returns all time intervals covered by existing data files for the
2669    /// specified data type and identifier. The intervals are sorted by start time and
2670    /// represent the complete data coverage available.
2671    ///
2672    /// # Parameters
2673    ///
2674    /// - `data_cls`: The data type directory name (e.g., "quotes", "trades").
2675    /// - `identifier`: Optional identifier to target a specific instrument's data. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
2676    ///
2677    /// # Returns
2678    ///
2679    /// Returns a vector of (start, end) tuples representing the covered intervals,
2680    /// sorted by start time, or an error if the operation fails.
2681    ///
2682    /// # Errors
2683    ///
2684    /// Returns an error if:
2685    /// - The directory path cannot be constructed.
2686    /// - Directory listing fails.
2687    /// - Filename parsing fails.
2688    ///
2689    /// # Examples
2690    ///
2691    /// ```rust,no_run
2692    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2693    ///
2694    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2695    ///
2696    /// // Get all intervals for quote data
2697    /// let intervals = catalog.get_intervals("quotes", Some("BTCUSD"))?;
2698    /// for (start, end) in intervals {
2699    ///     println!("Data available from {} to {}", start, end);
2700    /// }
2701    /// # Ok::<(), anyhow::Error>(())
2702    /// ```
2703    pub fn get_intervals(
2704        &self,
2705        data_cls: &str,
2706        identifier: Option<&str>,
2707    ) -> anyhow::Result<Vec<(u64, u64)>> {
2708        let directory = self.make_path(data_cls, identifier)?;
2709        let intervals = self.get_directory_intervals(&directory)?;
2710
2711        if identifier.is_none() {
2712            // `get_directory_intervals` already recursed through every per-identifier
2713            // subdirectory via `object_store.list`, so intervals from different
2714            // identifiers can overlap. Merge overlaps into a disjoint sorted union
2715            // so callers like `query_last_timestamp` see the true max end and
2716            // `consolidate_data_by_period` sees contiguous coverage.
2717            let mut merged: Vec<(u64, u64)> = Vec::new();
2718
2719            for interval in intervals {
2720                if let Some(last) = merged.last_mut()
2721                    && interval.0 <= last.1
2722                {
2723                    last.1 = last.1.max(interval.1);
2724                    continue;
2725                }
2726                merged.push(interval);
2727            }
2728
2729            return Ok(merged);
2730        }
2731
2732        // For bars, fall back to partial matching when the exact directory
2733        // doesn't exist (callers may pass an instrument_id like "EUR/USD.SIM"
2734        // but bars are stored under bar_type dirs like "EURUSD.SIM-1-MINUTE-...")
2735
2736        if !intervals.is_empty() || data_cls != "bars" {
2737            return Ok(intervals);
2738        }
2739
2740        let Some(identifier) = identifier else {
2741            return Ok(intervals);
2742        };
2743        let safe_id = urisafe_instrument_id(identifier);
2744
2745        // Use relative path so list_directory_stems doesn't double-prefix
2746        // for remote catalogs (make_path already includes base_path)
2747        let bars_subdir = format!("data/{data_cls}");
2748        let subdirs = self.list_directory_stems(&bars_subdir)?;
2749
2750        let mut all_intervals = Vec::new();
2751
2752        for subdir in &subdirs {
2753            let decoded = urlencoding::decode(subdir).unwrap_or(Cow::Borrowed(subdir));
2754
2755            if extract_bar_type_instrument_id(&decoded) == Some(safe_id.as_str()) {
2756                // Use decoded name to avoid double percent-encoding
2757                // (to_object_path uses Path::from which re-encodes)
2758                let subdir_path = self.make_path(data_cls, Some(&decoded))?;
2759                all_intervals.extend(self.get_directory_intervals(&subdir_path)?);
2760            }
2761        }
2762
2763        all_intervals.sort_by_key(|&(start, _)| start);
2764
2765        // Merge overlapping intervals from different bar types so that
2766        // last().1 reliably gives the maximum end timestamp
2767        let mut merged: Vec<(u64, u64)> = Vec::new();
2768
2769        for interval in all_intervals {
2770            if let Some(last) = merged.last_mut()
2771                && interval.0 <= last.1
2772            {
2773                last.1 = last.1.max(interval.1);
2774                continue;
2775            }
2776            merged.push(interval);
2777        }
2778
2779        Ok(merged)
2780    }
2781
2782    /// Gets the time intervals covered by Parquet files in a specific directory.
2783    ///
2784    /// This method scans a directory for Parquet files and extracts the timestamp ranges
2785    /// from their filenames. It's used internally by other methods to determine data coverage
2786    /// and is essential for interval-based operations like gap detection and consolidation.
2787    ///
2788    /// # Parameters
2789    ///
2790    /// - `directory`: The directory path to scan for Parquet files.
2791    ///
2792    /// # Returns
2793    ///
2794    /// Returns a vector of (start, end) tuples representing the time intervals covered
2795    /// by files in the directory, sorted by start timestamp. Returns an empty vector
2796    /// if the directory doesn't exist or contains no valid Parquet files.
2797    ///
2798    /// # Errors
2799    ///
2800    /// Returns an error if:
2801    /// - Object store listing operations fail.
2802    /// - Directory access is denied.
2803    ///
2804    /// # Notes
2805    ///
2806    /// - Only files with valid timestamp-based filenames are included.
2807    /// - Files with unparsable names are silently ignored.
2808    /// - The method works with both local and remote object stores.
2809    /// - Results are automatically sorted by start timestamp.
2810    ///
2811    /// # Examples
2812    ///
2813    /// ```rust,no_run
2814    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2815    ///
2816    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2817    /// let intervals = catalog.get_directory_intervals("data/quotes/EURUSD")?;
2818    ///
2819    /// for (start, end) in intervals {
2820    ///     println!("File covers {} to {}", start, end);
2821    /// }
2822    /// # Ok::<(), anyhow::Error>(())
2823    /// ```
2824    pub fn get_directory_intervals(&self, directory: &str) -> anyhow::Result<Vec<(u64, u64)>> {
2825        // Use object store for all operations
2826        // Convert directory to object path format (consistent with how files are written)
2827        // For local stores with empty base_path, to_object_path returns path as-is.
2828        // For remote stores, to_object_path preserves or prepends the catalog base path.
2829        let object_dir = self.to_object_path(directory)?;
2830        let list_result = self.execute_async(async {
2831            // Ensure trailing slash for directory listing
2832            let dir_str = format!("{}/", object_dir.as_ref());
2833            let prefix = ObjectPath::from(dir_str);
2834            let mut stream = self.object_store.list(Some(&prefix));
2835            let mut objects = Vec::new();
2836            while let Some(object) = stream.next().await {
2837                objects.push(object?);
2838            }
2839            Ok::<Vec<_>, anyhow::Error>(objects)
2840        })?;
2841
2842        let mut intervals = Vec::new();
2843
2844        for object in list_result {
2845            let path_str = object.location.to_string();
2846            if path_str.ends_with(".parquet")
2847                && let Some(interval) = parse_filename_timestamps(&path_str)
2848            {
2849                intervals.push(interval);
2850            }
2851        }
2852
2853        intervals.sort_by_key(|&(start, _)| start);
2854
2855        Ok(intervals)
2856    }
2857
2858    /// Constructs a directory path for storing data of a specific type and instrument.
2859    ///
2860    /// This method builds the hierarchical directory structure used by the catalog to organize
2861    /// data by type and instrument. The path follows the pattern: `{base_path}/data/{type_name}/{instrument_id}`.
2862    /// Instrument IDs are automatically converted to URI-safe format by removing forward slashes.
2863    ///
2864    /// # Parameters
2865    ///
2866    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars").
2867    /// - `identifier`: Optional identifier. Can be an `instrument_id` (e.g., "EUR/USD.SIM") or a `bar_type` (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL"). If provided, creates a subdirectory for the identifier. If `None`, returns the path to the data type directory.
2868    ///
2869    /// # Returns
2870    ///
2871    /// Returns the constructed directory path as a string, or an error if path construction fails.
2872    ///
2873    /// # Errors
2874    ///
2875    /// Returns an error if:
2876    /// - The instrument ID contains invalid characters that cannot be made URI-safe.
2877    /// - Path construction fails due to system limitations.
2878    ///
2879    /// # Path Structure
2880    ///
2881    /// - Without identifier: `{base_path}/data/{type_name}`.
2882    /// - With identifier: `{base_path}/data/{type_name}/{safe_identifier}`.
2883    /// - If `base_path` is empty: `data/{type_name}[/{safe_identifier}]`.
2884    ///
2885    /// # Examples
2886    ///
2887    /// ```rust,no_run
2888    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2889    ///
2890    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
2891    ///
2892    /// // Path for all quote data
2893    /// let quotes_path = catalog.make_path("quotes", None)?;
2894    /// // Returns: "/base/path/data/quotes"
2895    ///
2896    /// // Path for specific instrument quotes
2897    /// let eurusd_quotes = catalog.make_path("quotes", Some("EUR/USD"))?;
2898    /// // Returns: "/base/path/data/quotes/EURUSD" (slash removed)
2899    ///
2900    /// // Path for bar data with complex instrument ID
2901    /// let bars_path = catalog.make_path("bars", Some("BTC/USD-1H"))?;
2902    /// // Returns: "/base/path/data/bars/BTCUSD-1H"
2903    /// # Ok::<(), anyhow::Error>(())
2904    /// ```
2905    pub fn make_path(&self, type_name: &str, identifier: Option<&str>) -> anyhow::Result<String> {
2906        let mut components = vec!["data".to_string(), type_name.to_string()];
2907
2908        if let Some(id) = identifier {
2909            let safe_id = urisafe_instrument_id(id);
2910            components.push(safe_id);
2911        }
2912
2913        let path = make_object_store_path_owned(&self.base_path, components);
2914        Ok(path)
2915    }
2916
2917    /// Builds the directory path for custom data: `data/custom/{type_name}[/{identifier segments}]`.
2918    /// Identifier can contain `//` for subdirectories (normalized to `/`); path is safe for writing.
2919    ///
2920    /// # Errors
2921    ///
2922    /// Currently this function does not return an error; it keeps the catalog path-building
2923    /// API shape for compatibility with fallible path constructors.
2924    pub fn make_path_custom_data(
2925        &self,
2926        type_name: &str,
2927        identifier: Option<&str>,
2928    ) -> anyhow::Result<String> {
2929        let components = custom_data_path_components(type_name, identifier);
2930        let path = make_object_store_path_owned(&self.base_path, components);
2931        Ok(path)
2932    }
2933
2934    /// Helper method to rename a parquet file by moving it via object store operations
2935    fn rename_parquet_file(
2936        &self,
2937        directory: &str,
2938        old_start: u64,
2939        old_end: u64,
2940        new_start: u64,
2941        new_end: u64,
2942    ) -> anyhow::Result<()> {
2943        let old_filename =
2944            timestamps_to_filename(UnixNanos::from(old_start), UnixNanos::from(old_end));
2945        let old_path = format!("{directory}/{old_filename}");
2946        let old_object_path = self.to_object_path(&old_path)?;
2947
2948        let new_filename =
2949            timestamps_to_filename(UnixNanos::from(new_start), UnixNanos::from(new_end));
2950        let new_path = format!("{directory}/{new_filename}");
2951        let new_object_path = self.to_object_path(&new_path)?;
2952
2953        self.move_file(&old_object_path, &new_object_path)
2954    }
2955
2956    /// Converts a catalog path string to an [`ObjectPath`] for object store operations.
2957    ///
2958    /// This method handles the conversion between catalog-relative paths and object store paths,
2959    /// taking into account the catalog's base path configuration. It automatically preserves the
2960    /// base path prefix for remote catalogs and strips it for local catalog paths.
2961    ///
2962    /// # Parameters
2963    ///
2964    /// - `path`: The catalog path string to convert. Can be absolute or relative.
2965    ///
2966    /// # Returns
2967    ///
2968    /// Returns an [`ObjectPath`] suitable for use with object store operations.
2969    ///
2970    /// # Path Handling
2971    ///
2972    /// - If `base_path` is empty, the path is used as-is.
2973    /// - If `base_path` is set for a remote catalog, it's preserved or prepended.
2974    /// - If `base_path` is set for a local catalog, it's stripped from the path if present.
2975    /// - Trailing slashes and backslashes are automatically handled.
2976    /// - The resulting path is relative to the object store root.
2977    /// - All paths are normalized to use forward slashes (object store convention).
2978    ///
2979    /// # Errors
2980    ///
2981    /// Returns an error for remote catalogs when `path` is a full URI whose scheme/host
2982    /// does not match the catalog's own root (cross-bucket misuse). Without this guard
2983    /// the caller could silently write to or read from the wrong bucket.
2984    ///
2985    /// # Examples
2986    ///
2987    /// Local catalog paths (absolute or relative) strip the catalog's base directory:
2988    ///
2989    /// ```rust,no_run
2990    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
2991    /// # let catalog: ParquetDataCatalog = unimplemented!();
2992    /// let object_path = catalog.to_object_path("/base/data/quotes/file.parquet")?;
2993    /// // ObjectPath("data/quotes/file.parquet")
2994    /// # Ok::<(), anyhow::Error>(())
2995    /// ```
2996    ///
2997    /// Remote catalog paths (relative or full URI) preserve or prepend the base prefix:
2998    ///
2999    /// ```rust,no_run
3000    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3001    /// # let catalog: ParquetDataCatalog = unimplemented!();
3002    /// let object_path = catalog.to_object_path("data/trades/file.parquet")?;
3003    /// // ObjectPath("base/data/trades/file.parquet")
3004    /// # Ok::<(), anyhow::Error>(())
3005    /// ```
3006    pub fn to_object_path(&self, path: &str) -> anyhow::Result<ObjectPath> {
3007        Ok(ObjectPath::from(self.object_store_path(path)?))
3008    }
3009
3010    fn register_remote_object_store(&mut self) -> anyhow::Result<()> {
3011        if self.is_remote_uri() {
3012            let base_url = remote_store_root_url(&self.original_uri)?;
3013            self.session
3014                .register_object_store(&base_url, self.object_store.clone());
3015        }
3016
3017        Ok(())
3018    }
3019
3020    /// Converts a path string to [`ObjectPath`] using parse (no percent-encoding).
3021    ///
3022    /// Use this for paths that were returned by the object store (e.g. from `list()`),
3023    /// which may already be percent-encoded. Using [`Self::to_object_path`] (which uses
3024    /// `Path::from`) on such paths would double-encode (e.g. `%5E` -> `%255E`).
3025    ///
3026    /// # Errors
3027    ///
3028    /// Returns an error for the same cross-bucket case as [`Self::to_object_path`], or
3029    /// when the resulting string fails [`ObjectPath::parse`].
3030    pub fn to_object_path_parsed(&self, path: &str) -> anyhow::Result<ObjectPath> {
3031        let to_parse = self.object_store_path(path)?;
3032        ObjectPath::parse(&to_parse).map_err(anyhow::Error::from)
3033    }
3034
3035    fn object_store_path(&self, path: &str) -> anyhow::Result<String> {
3036        let normalized_path = path.replace('\\', "/");
3037
3038        if self.is_remote_uri() {
3039            if normalized_path.contains("://") {
3040                let path_under_root = self.remote_uri_object_path(&normalized_path)?;
3041                return Ok(self.path_under_base(&path_under_root));
3042            }
3043
3044            return Ok(self.path_under_base(&normalized_path));
3045        }
3046
3047        Ok(self.path_without_local_base(&normalized_path))
3048    }
3049
3050    fn remote_uri_object_path(&self, path: &str) -> anyhow::Result<String> {
3051        let path_url = url::Url::parse(path)
3052            .map_err(|e| anyhow::anyhow!("Failed to parse object store URI {path}: {e}"))?;
3053        if !is_remote_uri_scheme(path_url.scheme()) {
3054            anyhow::bail!(
3055                "URI {path} uses non-remote scheme {} for remote catalog at {}",
3056                path_url.scheme(),
3057                self.original_uri,
3058            );
3059        }
3060
3061        let catalog_root = remote_store_root_url(&self.original_uri)?;
3062        let path_root = remote_store_root_url(path)?;
3063        if catalog_root.as_str().trim_end_matches('/') != path_root.as_str().trim_end_matches('/') {
3064            anyhow::bail!(
3065                "Cross-store URI {path} (root {}) does not belong to catalog rooted at {} ({})",
3066                path_root.as_str().trim_end_matches('/'),
3067                self.original_uri,
3068                catalog_root.as_str().trim_end_matches('/'),
3069            );
3070        }
3071
3072        // The URL crate keeps the path component percent-encoded (e.g. `%5E`),
3073        // so preserve that encoding for `ObjectPath::parse` round-trips through
3074        // `object_store::list`/`get`.
3075        Ok(path_url.path().trim_start_matches('/').to_string())
3076    }
3077
3078    fn path_without_local_base(&self, path: &str) -> String {
3079        let base_path = if self.base_path.is_empty() {
3080            self.native_base_path_string()
3081        } else {
3082            self.base_path.clone()
3083        };
3084        let normalized_base = base_path.replace('\\', "/");
3085        let base = normalized_base.trim_end_matches('/');
3086
3087        if base.is_empty() {
3088            path.to_string()
3089        } else if path == base {
3090            String::new()
3091        } else if let Some(without_base) = path.strip_prefix(&format!("{base}/")) {
3092            without_base.to_string()
3093        } else {
3094            path.to_string()
3095        }
3096    }
3097
3098    fn path_under_base(&self, path: &str) -> String {
3099        let normalized_path = path.replace('\\', "/");
3100        let path = normalized_path
3101            .trim_start_matches('/')
3102            .trim_end_matches('/');
3103
3104        if self.base_path.is_empty() {
3105            return path.to_string();
3106        }
3107
3108        let normalized_base = self.base_path.replace('\\', "/");
3109        let base = normalized_base
3110            .trim_start_matches('/')
3111            .trim_end_matches('/');
3112
3113        if base.is_empty() || path == base || path.starts_with(&format!("{base}/")) {
3114            path.to_string()
3115        } else if path.is_empty() {
3116            base.to_string()
3117        } else {
3118            make_object_store_path(base, &[path])
3119        }
3120    }
3121
3122    #[allow(dead_code)]
3123    fn to_file_path(path: &ObjectPath) -> String {
3124        path.to_string()
3125    }
3126
3127    /// Helper method to move a file using object store rename operation
3128    ///
3129    /// # Errors
3130    ///
3131    /// Returns an error if the object store rename operation fails.
3132    pub fn move_file(&self, old_path: &ObjectPath, new_path: &ObjectPath) -> anyhow::Result<()> {
3133        self.execute_async(async {
3134            self.object_store
3135                .rename(old_path, new_path)
3136                .await
3137                .map_err(anyhow::Error::from)
3138        })
3139    }
3140
3141    /// Helper method to execute async operations with a runtime
3142    ///
3143    /// # Errors
3144    ///
3145    /// Returns an error if the future resolves to an error.
3146    pub fn execute_async<F, R>(&self, future: F) -> anyhow::Result<R>
3147    where
3148        F: std::future::Future<Output = anyhow::Result<R>>,
3149    {
3150        super::block_on(get_runtime().handle(), future)
3151    }
3152
3153    /// Lists directory stems (directory names without path) in a subdirectory.
3154    ///
3155    /// This method scans a subdirectory and returns the names of all immediate
3156    /// subdirectories. It's used to list data types, backtest runs, and live runs.
3157    ///
3158    /// # Parameters
3159    ///
3160    /// - `subdirectory`: The subdirectory path to scan (e.g., "data", "backtest", "live").
3161    ///
3162    /// # Returns
3163    ///
3164    /// Returns a vector of directory names (stems) found in the subdirectory,
3165    /// or an error if the operation fails.
3166    ///
3167    /// # Errors
3168    ///
3169    /// Returns an error if:
3170    /// - Object store listing operations fail.
3171    /// - Directory access is denied.
3172    ///
3173    /// # Examples
3174    ///
3175    /// ```rust,no_run
3176    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3177    ///
3178    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3179    ///
3180    /// // List all data types
3181    /// let data_types = catalog.list_directory_stems("data")?;
3182    /// for data_type in data_types {
3183    ///     println!("Found data type: {}", data_type);
3184    /// }
3185    /// # Ok::<(), anyhow::Error>(())
3186    /// ```
3187    pub fn list_directory_stems(&self, subdirectory: &str) -> anyhow::Result<Vec<String>> {
3188        // For local filesystem paths, use filesystem operations to detect empty directories
3189        // For remote object stores, we can only list directories that contain files
3190        if !self.is_remote_uri() {
3191            let directory = PathBuf::from(self.native_base_path_string()).join(subdirectory);
3192
3193            // Check if directory exists
3194            if !directory.exists() {
3195                return Ok(Vec::new());
3196            }
3197
3198            // List all entries in the directory
3199            let mut directories = Vec::new();
3200
3201            if let Ok(entries) = std::fs::read_dir(&directory) {
3202                for entry in entries.flatten() {
3203                    if let Ok(file_type) = entry.file_type()
3204                        && file_type.is_dir()
3205                    {
3206                        // Use file_name() to get the directory name (not file_stem which removes extension)
3207                        if let Some(name) = entry.path().file_name() {
3208                            directories.push(name.to_string_lossy().to_string());
3209                        }
3210                    }
3211                }
3212            }
3213            directories.sort();
3214            return Ok(directories);
3215        }
3216
3217        // For remote URIs, use object store listing (only lists directories with files)
3218        let directory = make_object_store_path(&self.base_path, &[subdirectory]);
3219
3220        let list_result = self.execute_async(async {
3221            let prefix = ObjectPath::from(format!("{directory}/"));
3222            let mut stream = self.object_store.list(Some(&prefix));
3223            let mut directories = Vec::new();
3224            let mut seen_dirs = std::collections::HashSet::new();
3225
3226            while let Some(object) = stream.next().await {
3227                let object = object?;
3228                let path_str = object.location.to_string();
3229
3230                // Extract the immediate subdirectory name
3231                if let Some(relative_path) = path_str.strip_prefix(&format!("{directory}/")) {
3232                    let parts: Vec<&str> = relative_path.split('/').collect();
3233                    if let Some(first_part) = parts.first()
3234                        && !first_part.is_empty()
3235                        && !seen_dirs.contains(*first_part)
3236                    {
3237                        seen_dirs.insert(first_part.to_string());
3238                        directories.push(first_part.to_string());
3239                    }
3240                }
3241            }
3242
3243            Ok::<Vec<String>, anyhow::Error>(directories)
3244        })?;
3245
3246        Ok(list_result)
3247    }
3248
3249    /// Lists all data types available in the catalog.
3250    ///
3251    /// This method returns the names of all data type directories in the catalog.
3252    /// Data types correspond to different kinds of market data (e.g., "quotes", "trades", "bars").
3253    ///
3254    /// # Returns
3255    ///
3256    /// Returns a vector of data type names, or an error if the operation fails.
3257    ///
3258    /// # Errors
3259    ///
3260    /// Returns an error if:
3261    /// - Object store listing operations fail.
3262    /// - Directory access is denied.
3263    ///
3264    /// # Examples
3265    ///
3266    /// ```rust,no_run
3267    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3268    ///
3269    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3270    ///
3271    /// // List all data types
3272    /// let data_types = catalog.list_data_types()?;
3273    /// for data_type in data_types {
3274    ///     println!("Available data type: {}", data_type);
3275    /// }
3276    /// # Ok::<(), anyhow::Error>(())
3277    /// ```
3278    pub fn list_data_types(&self) -> anyhow::Result<Vec<String>> {
3279        self.list_directory_stems("data")
3280    }
3281
3282    /// Data types that are not persisted by the Rust feather writer or catalog.
3283    fn is_excluded_stream_data_type(_name: &str) -> bool {
3284        false
3285    }
3286
3287    /// Lists all backtest run IDs available in the catalog.
3288    ///
3289    /// This method returns the names of all backtest run directories in the catalog.
3290    /// Each backtest run corresponds to a specific backtest execution instance.
3291    ///
3292    /// # Returns
3293    ///
3294    /// Returns a vector of backtest run IDs, or an error if the operation fails.
3295    ///
3296    /// # Errors
3297    ///
3298    /// Returns an error if:
3299    /// - Object store listing operations fail.
3300    /// - Directory access is denied.
3301    ///
3302    /// # Examples
3303    ///
3304    /// ```rust,no_run
3305    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3306    ///
3307    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3308    ///
3309    /// // List all backtest runs
3310    /// let runs = catalog.list_backtest_runs()?;
3311    /// for run_id in runs {
3312    ///     println!("Backtest run: {}", run_id);
3313    /// }
3314    /// # Ok::<(), anyhow::Error>(())
3315    /// ```
3316    pub fn list_backtest_runs(&self) -> anyhow::Result<Vec<String>> {
3317        self.list_directory_stems("backtest")
3318    }
3319
3320    /// Lists all live run IDs available in the catalog.
3321    ///
3322    /// This method returns the names of all live run directories in the catalog.
3323    /// Each live run corresponds to a specific live trading execution instance.
3324    ///
3325    /// # Returns
3326    ///
3327    /// Returns a vector of live run IDs, or an error if the operation fails.
3328    ///
3329    /// # Errors
3330    ///
3331    /// Returns an error if:
3332    /// - Object store listing operations fail.
3333    /// - Directory access is denied.
3334    ///
3335    /// # Examples
3336    ///
3337    /// ```rust,no_run
3338    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3339    ///
3340    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3341    ///
3342    /// // List all live runs
3343    /// let runs = catalog.list_live_runs()?;
3344    /// for run_id in runs {
3345    ///     println!("Live run: {}", run_id);
3346    /// }
3347    /// # Ok::<(), anyhow::Error>(())
3348    /// ```
3349    pub fn list_live_runs(&self) -> anyhow::Result<Vec<String>> {
3350        self.list_directory_stems("live")
3351    }
3352
3353    /// Reads data from a live run instance.
3354    ///
3355    /// This method reads all data associated with a specific live run instance
3356    /// from feather files stored in the catalog.
3357    ///
3358    /// # Parameters
3359    ///
3360    /// - `instance_id`: The ID of the live run instance to read.
3361    ///
3362    /// # Returns
3363    ///
3364    /// Returns a vector of `Data` objects from the live run, sorted by timestamp,
3365    /// or an error if the operation fails.
3366    ///
3367    /// # Errors
3368    ///
3369    /// Returns an error if:
3370    /// - The instance ID doesn't exist.
3371    /// - Feather file reading fails.
3372    /// - Data deserialization fails.
3373    ///
3374    /// # Note
3375    ///
3376    /// This method is currently not fully implemented. Feather file reading
3377    /// requires complex deserialization logic that needs to be added.
3378    ///
3379    /// # Examples
3380    ///
3381    /// ```rust,no_run
3382    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3383    ///
3384    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3385    ///
3386    /// // Read data from a live run
3387    /// let data = catalog.read_live_run("instance-123")?;
3388    /// for item in data {
3389    ///     println!("Data: {:?}", item);
3390    /// }
3391    /// # Ok::<(), anyhow::Error>(())
3392    /// ```
3393    pub fn read_live_run(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
3394        self.read_run_data("live", instance_id)
3395    }
3396
3397    /// Reads data from a backtest run instance.
3398    ///
3399    /// This method reads all data associated with a specific backtest run instance
3400    /// from feather files stored in the catalog.
3401    ///
3402    /// # Parameters
3403    ///
3404    /// - `instance_id`: The ID of the backtest run instance to read.
3405    ///
3406    /// # Returns
3407    ///
3408    /// Returns a vector of `Data` objects from the backtest run, sorted by timestamp,
3409    /// or an error if the operation fails.
3410    ///
3411    /// # Errors
3412    ///
3413    /// Returns an error if:
3414    /// - The instance ID doesn't exist.
3415    /// - Feather file reading fails.
3416    /// - Data deserialization fails.
3417    ///
3418    /// # Examples
3419    ///
3420    /// ```rust,no_run
3421    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3422    ///
3423    /// let catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3424    ///
3425    /// // Read data from a backtest run
3426    /// let data = catalog.read_backtest("instance-123")?;
3427    /// for item in data {
3428    ///     println!("Data: {:?}", item);
3429    /// }
3430    /// # Ok::<(), anyhow::Error>(())
3431    /// ```
3432    pub fn read_backtest(&self, instance_id: &str) -> anyhow::Result<Vec<Data>> {
3433        self.read_run_data("backtest", instance_id)
3434    }
3435
3436    /// Helper function to read data from a run instance (backtest or live).
3437    ///
3438    /// This function reads all data associated with a specific run instance
3439    /// from feather files stored in the catalog.
3440    ///
3441    /// # Parameters
3442    ///
3443    /// - `subdirectory`: The subdirectory name ("backtest" or "live").
3444    /// - `instance_id`: The ID of the run instance to read.
3445    ///
3446    /// # Returns
3447    ///
3448    /// Returns a vector of `Data` objects from the run, sorted by timestamp,
3449    /// or an error if the operation fails.
3450    #[expect(
3451        clippy::too_many_lines,
3452        reason = "run data loading keeps local and remote discovery paths together"
3453    )]
3454    fn read_run_data(&self, subdirectory: &str, instance_id: &str) -> anyhow::Result<Vec<Data>> {
3455        // List all data types in the instance directory
3456        let instance_dir = make_object_store_path(&self.base_path, &[subdirectory, instance_id]);
3457
3458        // List directories under the instance directory
3459        let data_types = if self.is_remote_uri() {
3460            // For remote URIs, use object store listing
3461
3462            self.execute_async(async {
3463                let prefix = ObjectPath::from(format!("{instance_dir}/"));
3464                let mut stream = self.object_store.list(Some(&prefix));
3465                let mut directories = Vec::new();
3466                let mut seen_dirs = std::collections::HashSet::new();
3467
3468                while let Some(object) = stream.next().await {
3469                    let object = object?;
3470                    let path_str = object.location.to_string();
3471
3472                    // Extract the immediate subdirectory name
3473                    if let Some(relative_path) = path_str.strip_prefix(&format!("{instance_dir}/"))
3474                    {
3475                        let parts: Vec<&str> = relative_path.split('/').collect();
3476                        if let Some(first_part) = parts.first()
3477                            && !first_part.is_empty()
3478                            && !seen_dirs.contains(*first_part)
3479                        {
3480                            seen_dirs.insert(first_part.to_string());
3481                            directories.push(first_part.to_string());
3482                        }
3483                    }
3484                }
3485
3486                Ok::<Vec<String>, anyhow::Error>(directories)
3487            })?
3488        } else {
3489            // For local filesystem paths
3490            let directory = PathBuf::from(self.native_base_path_string())
3491                .join(subdirectory)
3492                .join(instance_id);
3493
3494            if !directory.exists() {
3495                return Ok(Vec::new());
3496            }
3497
3498            let mut directories = Vec::new();
3499
3500            if let Ok(entries) = std::fs::read_dir(&directory) {
3501                for entry in entries.flatten() {
3502                    if let Ok(file_type) = entry.file_type()
3503                        && file_type.is_dir()
3504                        && let Some(name) = entry.path().file_name()
3505                    {
3506                        directories.push(name.to_string_lossy().to_string());
3507                    }
3508                }
3509            }
3510            directories.sort();
3511            directories
3512        };
3513
3514        if data_types.is_empty() {
3515            // No data types found - return empty vector
3516            return Ok(Vec::new());
3517        }
3518
3519        let mut all_data: Vec<Data> = Vec::new();
3520
3521        // Process each persisted data type.
3522        for data_cls in data_types
3523            .into_iter()
3524            .filter(|s| !Self::is_excluded_stream_data_type(s))
3525        {
3526            // List all feather files for this data type
3527            let feather_files = self.list_feather_files(
3528                subdirectory,
3529                instance_id,
3530                &data_cls,
3531                None, // No identifier filtering - read all
3532            )?;
3533
3534            if feather_files.is_empty() {
3535                continue; // Skip if no files found
3536            }
3537
3538            // Process each feather file
3539            for file_path in feather_files {
3540                // Read the feather file (may contain multiple batches)
3541                let batches = self.read_feather_file(&file_path)?;
3542
3543                if batches.is_empty() {
3544                    continue; // Skip empty or invalid files
3545                }
3546
3547                // Convert RecordBatches to Data objects based on data_cls
3548                let file_data: Vec<Data> = match data_cls.as_str() {
3549                    "quotes" => {
3550                        let quotes: Vec<QuoteTick> =
3551                            Self::convert_record_batches_to_data(batches, false)?;
3552                        quotes.into_iter().map(Data::from).collect()
3553                    }
3554                    "trades" => {
3555                        let trades: Vec<TradeTick> =
3556                            Self::convert_record_batches_to_data(batches, false)?;
3557                        trades.into_iter().map(Data::from).collect()
3558                    }
3559                    "order_book_deltas" => {
3560                        let deltas: Vec<OrderBookDelta> =
3561                            Self::convert_record_batches_to_data(batches, false)?;
3562                        deltas.into_iter().map(Data::from).collect()
3563                    }
3564                    "order_book_depths" => {
3565                        let depths: Vec<OrderBookDepth10> =
3566                            Self::convert_record_batches_to_data(batches, false)?;
3567                        depths.into_iter().map(Data::from).collect()
3568                    }
3569                    "bars" => {
3570                        let bars: Vec<Bar> = Self::convert_record_batches_to_data(batches, false)?;
3571                        bars.into_iter().map(Data::from).collect()
3572                    }
3573                    "index_prices" => {
3574                        let prices: Vec<IndexPriceUpdate> =
3575                            Self::convert_record_batches_to_data(batches, false)?;
3576                        prices.into_iter().map(Data::from).collect()
3577                    }
3578                    "mark_prices" => {
3579                        let prices: Vec<MarkPriceUpdate> =
3580                            Self::convert_record_batches_to_data(batches, false)?;
3581                        prices.into_iter().map(Data::from).collect()
3582                    }
3583                    "funding_rate_update" => {
3584                        let funding_rates: Vec<FundingRateUpdate> =
3585                            Self::convert_record_batches_to_data(batches, false)?;
3586                        funding_rates.into_iter().map(Data::from).collect()
3587                    }
3588                    "option_greeks" => {
3589                        let greeks: Vec<OptionGreeks> =
3590                            Self::convert_record_batches_to_data(batches, false)?;
3591                        greeks.into_iter().map(Data::from).collect()
3592                    }
3593                    "instrument_status" => {
3594                        let statuses: Vec<InstrumentStatus> =
3595                            Self::convert_record_batches_to_data(batches, false)?;
3596                        statuses.into_iter().map(Data::from).collect()
3597                    }
3598                    "instrument_closes" => {
3599                        let closes: Vec<InstrumentClose> =
3600                            Self::convert_record_batches_to_data(batches, false)?;
3601                        closes.into_iter().map(Data::from).collect()
3602                    }
3603                    _ => {
3604                        if data_cls.starts_with("custom/") {
3605                            Self::decode_custom_batches_to_data(batches, false)?
3606                        } else {
3607                            // Unknown data type - skip it
3608                            continue;
3609                        }
3610                    }
3611                };
3612
3613                all_data.extend(file_data);
3614            }
3615        }
3616
3617        // Sort all data by timestamp (ts_init)
3618        all_data.sort_by(|a, b| {
3619            let ts_a = a.ts_init();
3620            let ts_b = b.ts_init();
3621            ts_a.cmp(&ts_b)
3622        });
3623
3624        Ok(all_data)
3625    }
3626
3627    /// Decodes multiple record batches of custom data (`data_cls` starts with "custom/") into a single
3628    /// `Vec<Data>`. Optionally replaces `ts_init` column with `ts_event` before decoding.
3629    ///
3630    /// # Errors
3631    ///
3632    /// Returns an error if any batch fails to decode.
3633    fn decode_custom_batches_to_data(
3634        batches: Vec<RecordBatch>,
3635        use_ts_event_for_ts_init: bool,
3636    ) -> anyhow::Result<Vec<Data>> {
3637        orchestration_decode_custom_batches_to_data(batches, use_ts_event_for_ts_init)
3638    }
3639
3640    /// Decodes a `RecordBatch` to Data objects based on metadata.
3641    ///
3642    /// This method determines the data type from metadata and decodes the batch accordingly.
3643    /// It supports both standard data types and custom data types when `allow_custom_fallback`
3644    /// is true (e.g. when called from `decode_custom_batches_to_data` for files under
3645    /// `custom/`). When false, unknown type names produce an error instead of attempting
3646    /// custom decode, so malformed or typo'd built-in metadata fails explicitly.
3647    ///
3648    /// # Parameters
3649    ///
3650    /// - `metadata`: Schema metadata containing type information.
3651    /// - `batch`: The `RecordBatch` to decode.
3652    /// - `allow_custom_fallback`: If true, unknown `type_name` is decoded via custom data
3653    ///   registry; if false, unknown `type_name` returns an error.
3654    ///
3655    /// # Returns
3656    ///
3657    /// Returns a vector of Data enum variants.
3658    ///
3659    /// # Errors
3660    ///
3661    /// Returns an error if decoding fails or the type is unknown (and custom fallback not allowed).
3662    #[allow(dead_code)] // used by tests
3663    fn decode_batch_to_data(
3664        metadata: &std::collections::HashMap<String, String>,
3665        batch: RecordBatch,
3666        allow_custom_fallback: bool,
3667    ) -> anyhow::Result<Vec<Data>> {
3668        orchestration_decode_batch_to_data(metadata, batch, allow_custom_fallback)
3669    }
3670
3671    /// Converts stream data from feather files to parquet files.
3672    ///
3673    /// This method reads data from feather files generated during a backtest or live run
3674    /// and writes it to the catalog in parquet format. It's useful for converting temporary
3675    /// stream data into a more permanent and queryable format.
3676    ///
3677    /// # Parameters
3678    ///
3679    /// - `instance_id`: The ID of the backtest or live run instance.
3680    /// - `data_cls`: The data class name (e.g., "quotes", "trades", "bars").
3681    /// - `subdirectory`: The subdirectory containing the feather files. Either "backtest" or "live" (default: "backtest").
3682    /// - `identifiers`: Optional list of identifiers to filter by (instrument IDs or bar types).
3683    /// - `use_ts_event_for_ts_init`: If true, replaces the `ts_init` column with `ts_event` column values before deserializing.
3684    ///
3685    /// # Returns
3686    ///
3687    /// Returns `Ok(())` on success, or an error if the operation fails.
3688    ///
3689    /// # Errors
3690    ///
3691    /// Returns an error if:
3692    /// - The instance ID doesn't exist.
3693    /// - Feather file listing fails.
3694    /// - Feather file reading fails.
3695    /// - Writing to parquet fails.
3696    ///
3697    /// # Note
3698    ///
3699    /// This method converts directly between Arrow IPC stream batches and Parquet batches without
3700    /// materializing Nautilus data objects. It requires:
3701    /// - Listing feather files in the specified subdirectory
3702    /// - Reading feather files (Arrow IPC stream reading)
3703    /// - Applying table-only stream conversion transforms
3704    /// - Writing Arrow batches to the catalog
3705    ///
3706    /// # Examples
3707    ///
3708    /// ```rust,no_run
3709    /// use nautilus_persistence::backend::catalog::ParquetDataCatalog;
3710    ///
3711    /// let mut catalog = ParquetDataCatalog::from_uri("/tmp/nautilus_data", None, None, None, None)?;
3712    ///
3713    /// // Convert backtest stream data to parquet
3714    /// catalog.convert_stream_to_data("instance-123", "quotes", Some("backtest"), None, false)?;
3715    /// # Ok::<(), anyhow::Error>(())
3716    /// ```
3717    /// Lists feather files for a specific data class in a subdirectory.
3718    ///
3719    /// This helper function finds all `.feather` files in the specified subdirectory
3720    /// (backtest or live) for the given instance ID and data class.
3721    fn list_feather_files(
3722        &self,
3723        subdirectory: &str,
3724        instance_id: &str,
3725        data_name: &str,
3726        identifiers: Option<&[String]>,
3727    ) -> anyhow::Result<Vec<String>> {
3728        let base_dir = make_object_store_path(&self.base_path, &[subdirectory, instance_id]);
3729
3730        let mut files = Vec::new();
3731
3732        let list_result = self.execute_async(async {
3733            let prefix = ObjectPath::from(format!("{base_dir}/"));
3734            let mut stream = self.object_store.list(Some(&prefix));
3735            let mut feather_files = Vec::new();
3736
3737            while let Some(object) = stream.next().await {
3738                let object = object?;
3739                let path_str = object.location.to_string();
3740
3741                if !path_str.ends_with(".feather") {
3742                    continue;
3743                }
3744
3745                let Some(relative_path) = path_str.strip_prefix(&format!("{base_dir}/")) else {
3746                    continue;
3747                };
3748
3749                if let Some(data_relative_path) =
3750                    relative_path.strip_prefix(&format!("{data_name}/"))
3751                {
3752                    if let Some(identifiers) = identifiers {
3753                        let identifier_path = data_relative_path
3754                            .split_once('/')
3755                            .map_or(data_relative_path, |(identifier, _)| identifier);
3756
3757                        if !Self::stream_identifier_matches(identifier_path, identifiers) {
3758                            continue;
3759                        }
3760                    }
3761
3762                    feather_files.push(path_str);
3763                } else if Self::is_flat_stream_file(relative_path, data_name) {
3764                    feather_files.push(path_str);
3765                }
3766            }
3767
3768            Ok::<Vec<String>, anyhow::Error>(feather_files)
3769        })?;
3770
3771        files.extend(list_result);
3772        files.sort();
3773        Ok(files)
3774    }
3775
3776    fn is_flat_stream_file(relative_path: &str, data_name: &str) -> bool {
3777        if relative_path.contains('/') {
3778            return false;
3779        }
3780
3781        let Some(file_stem) = relative_path.strip_suffix(".feather") else {
3782            return false;
3783        };
3784        let Some(timestamp) = file_stem.strip_prefix(&format!("{data_name}_")) else {
3785            return false;
3786        };
3787
3788        !timestamp.is_empty() && timestamp.chars().all(|ch| ch.is_ascii_digit())
3789    }
3790
3791    fn stream_identifier_matches(candidate: &str, identifiers: &[String]) -> bool {
3792        identifiers.iter().any(|id| {
3793            let safe_id = urisafe_instrument_id(id);
3794            candidate.contains(id) || candidate.contains(&safe_id)
3795        })
3796    }
3797
3798    /// Reads a feather file and returns all `RecordBatches`.
3799    ///
3800    /// This function reads an Arrow IPC stream file from the object store
3801    /// and returns all `RecordBatches` contained within it.
3802    fn read_feather_file(&self, file_path: &str) -> anyhow::Result<Vec<RecordBatch>> {
3803        use datafusion::arrow::ipc::reader::StreamReader;
3804
3805        let bytes = self.execute_async(async {
3806            let path = ObjectPath::from(file_path);
3807            let result = self.object_store.get(&path).await?;
3808            let bytes = result.bytes().await?;
3809            Ok::<_, anyhow::Error>(bytes)
3810        })?;
3811
3812        if bytes.is_empty() {
3813            return Ok(Vec::new());
3814        }
3815
3816        // Read the Arrow IPC stream
3817        let cursor = Cursor::new(bytes.as_ref());
3818        let reader = StreamReader::try_new(cursor, None)
3819            .map_err(|e| anyhow::anyhow!("Failed to create StreamReader: {e}"))?;
3820
3821        // Read all batches
3822        let mut batches = Vec::new();
3823
3824        for batch_result in reader {
3825            let batch = batch_result.map_err(|e| anyhow::anyhow!("Failed to read batch: {e}"))?;
3826            batches.push(batch);
3827        }
3828
3829        Ok(batches)
3830    }
3831
3832    /// Converts `RecordBatches` to Data objects, optionally replacing `ts_init` with `ts_event`.
3833    fn convert_record_batches_to_data<T>(
3834        batches: Vec<RecordBatch>,
3835        use_ts_event_for_ts_init: bool,
3836    ) -> anyhow::Result<Vec<T>>
3837    where
3838        T: DecodeDataFromRecordBatch + TryFrom<Data>,
3839    {
3840        Self::convert_record_batches_to_data_with_bar_type_conversion(
3841            batches,
3842            use_ts_event_for_ts_init,
3843            false,
3844        )
3845    }
3846
3847    /// Converts `RecordBatches` to Data objects with optional transforms for stream conversion.
3848    fn convert_record_batches_to_data_with_bar_type_conversion<T>(
3849        batches: Vec<RecordBatch>,
3850        use_ts_event_for_ts_init: bool,
3851        convert_bar_type_to_external: bool,
3852    ) -> anyhow::Result<Vec<T>>
3853    where
3854        T: DecodeDataFromRecordBatch + TryFrom<Data>,
3855    {
3856        if batches.is_empty() {
3857            return Ok(Vec::new());
3858        }
3859
3860        let schema = batches[0].schema();
3861        let mut metadata = schema.metadata().clone();
3862
3863        if convert_bar_type_to_external {
3864            convert_bar_type_metadata_to_external(&mut metadata);
3865        }
3866
3867        let mut all_data = Vec::new();
3868
3869        for mut batch in batches {
3870            if use_ts_event_for_ts_init {
3871                let column_names: Vec<String> =
3872                    schema.fields().iter().map(|f| f.name().clone()).collect();
3873
3874                let ts_event_idx = column_names
3875                    .iter()
3876                    .position(|n| n == "ts_event")
3877                    .ok_or_else(|| anyhow::anyhow!("ts_event column not found"))?;
3878                let ts_init_idx = column_names
3879                    .iter()
3880                    .position(|n| n == "ts_init")
3881                    .ok_or_else(|| anyhow::anyhow!("ts_init column not found"))?;
3882
3883                let mut new_columns = batch.columns().to_vec();
3884                new_columns[ts_init_idx] = new_columns[ts_event_idx].clone();
3885
3886                batch = RecordBatch::try_new(schema.clone(), new_columns)
3887                    .map_err(|e| anyhow::anyhow!("Failed to create new batch: {e}"))?;
3888            }
3889
3890            let data_vec = T::decode_data_batch(&metadata, batch)
3891                .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
3892
3893            all_data.extend(data_vec);
3894        }
3895
3896        Ok(to_variant::<T>(all_data))
3897    }
3898
3899    /// Converts `RecordBatches` directly to strongly typed values.
3900    fn convert_record_batches_to_typed<T>(batches: Vec<RecordBatch>) -> anyhow::Result<Vec<T>>
3901    where
3902        T: DecodeTypedFromRecordBatch,
3903    {
3904        if batches.is_empty() {
3905            return Ok(Vec::new());
3906        }
3907
3908        let mut all_data = Vec::new();
3909
3910        for batch in batches {
3911            let metadata = batch.schema().metadata().clone();
3912            let decoded = T::decode_typed_batch(&metadata, batch)
3913                .map_err(|e| anyhow::anyhow!("Failed to decode batch: {e}"))?;
3914            all_data.extend(decoded);
3915        }
3916
3917        Ok(all_data)
3918    }
3919
3920    /// Converts stream data from feather files to catalog data.
3921    ///
3922    /// # Errors
3923    ///
3924    /// Returns an error if stream file discovery, record batch conversion, or catalog
3925    /// writes fail.
3926    pub fn convert_stream_to_data(
3927        &mut self,
3928        instance_id: &str,
3929        data_cls: &str,
3930        subdirectory: Option<&str>,
3931        identifiers: Option<&[String]>,
3932        use_ts_event_for_ts_init: bool,
3933    ) -> anyhow::Result<()> {
3934        let subdirectory = subdirectory.unwrap_or("backtest");
3935
3936        // Skip unsupported stream data types without error.
3937        if Self::is_excluded_stream_data_type(data_cls) {
3938            return Ok(());
3939        }
3940
3941        // Convert data class name to filename (e.g., "quotes" -> "quotes")
3942        // The data_cls should already be in the correct format (snake_case)
3943        let data_name = to_snake_case(data_cls);
3944
3945        // List all feather files for this data class
3946        let feather_files =
3947            self.list_feather_files(subdirectory, instance_id, &data_name, identifiers)?;
3948
3949        if feather_files.is_empty() {
3950            return Ok(());
3951        }
3952
3953        if !Self::is_supported_stream_data_type(&data_name) {
3954            anyhow::bail!("Unknown data class: {data_cls}");
3955        }
3956
3957        // Process each feather file independently so that each file's identifier
3958        // (instrument_id or bar_type from schema metadata) is preserved when writing
3959        // to parquet. This matches the Python _convert_feather_table_to_parquet approach.
3960        for file_path in feather_files {
3961            let batches = self.read_feather_file(&file_path)?;
3962            self.convert_feather_batches_to_parquet(
3963                &data_name,
3964                &file_path,
3965                batches,
3966                use_ts_event_for_ts_init,
3967            )?;
3968        }
3969
3970        Ok(())
3971    }
3972
3973    fn convert_feather_batches_to_parquet(
3974        &self,
3975        data_name: &str,
3976        feather_path: &str,
3977        batches: Vec<RecordBatch>,
3978        use_ts_event_for_ts_init: bool,
3979    ) -> anyhow::Result<()> {
3980        let Some(batch) = Self::apply_stream_conversion_transforms(
3981            batches,
3982            use_ts_event_for_ts_init,
3983        )
3984        .map_err(|e| {
3985            anyhow::anyhow!("Failed to apply stream conversion transforms for {feather_path}: {e}")
3986        })?
3987        else {
3988            return Ok(());
3989        };
3990
3991        let (start_ts, end_ts) = Self::ts_init_range(&batch).map_err(|e| {
3992            anyhow::anyhow!("Failed to determine ts_init range for {feather_path}: {e}")
3993        })?;
3994        let identifier = Self::identifier_from_batch_or_path(&batch, data_name, feather_path);
3995        let directory = if let Some(type_name) = data_name.strip_prefix("custom/") {
3996            self.make_path_custom_data(type_name, identifier.as_deref())?
3997        } else {
3998            self.make_path(data_name, identifier.as_deref())?
3999        };
4000        let filename = timestamps_to_filename(UnixNanos::from(start_ts), UnixNanos::from(end_ts));
4001        let path = PathBuf::from(format!("{directory}/{filename}"));
4002        let object_path = self.to_object_path(&path.to_string_lossy())?;
4003
4004        let file_exists = self.execute_async(async {
4005            let exists = self.object_store.head(&object_path).await.is_ok();
4006            Ok::<_, anyhow::Error>(exists)
4007        })?;
4008
4009        if file_exists {
4010            log::info!("File {} already exists, skipping write", path.display());
4011            return Ok(());
4012        }
4013
4014        let current_intervals = self.get_directory_intervals(&directory)?;
4015        let mut new_intervals = current_intervals.clone();
4016        new_intervals.push((start_ts, end_ts));
4017
4018        if !are_intervals_disjoint(&new_intervals) {
4019            anyhow::bail!(
4020                "Writing file {filename} with interval ({start_ts}, {end_ts}) would create \
4021                non-disjoint intervals. Existing intervals: {current_intervals:?}"
4022            );
4023        }
4024
4025        let batches = vec![batch];
4026        self.execute_async(async {
4027            write_batches_to_object_store(
4028                &batches,
4029                self.object_store.clone(),
4030                &object_path,
4031                Some(self.compression),
4032                Some(self.max_row_group_size),
4033                None,
4034            )
4035            .await
4036        })?;
4037
4038        Ok(())
4039    }
4040
4041    fn apply_stream_conversion_transforms(
4042        mut batches: Vec<RecordBatch>,
4043        use_ts_event_for_ts_init: bool,
4044    ) -> anyhow::Result<Option<RecordBatch>> {
4045        if batches.is_empty() {
4046            return Ok(None);
4047        }
4048
4049        let schema = batches[0].schema();
4050        let mut metadata = schema.metadata().clone();
4051        let mut metadata_changed = false;
4052
4053        if convert_bar_type_metadata_to_external(&mut metadata) {
4054            metadata_changed = true;
4055        }
4056
4057        let schema = if metadata_changed {
4058            Arc::new(schema.as_ref().clone().with_metadata(metadata))
4059        } else {
4060            schema
4061        };
4062
4063        if use_ts_event_for_ts_init {
4064            let ts_event_idx = schema
4065                .index_of("ts_event")
4066                .map_err(|_| anyhow::anyhow!("ts_event column not found"))?;
4067            let ts_init_idx = schema
4068                .index_of("ts_init")
4069                .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
4070
4071            for batch in &mut batches {
4072                let mut columns = batch.columns().to_vec();
4073                columns[ts_init_idx] = columns[ts_event_idx].clone();
4074
4075                *batch = RecordBatch::try_new(schema.clone(), columns).map_err(|e| {
4076                    anyhow::anyhow!("Failed to create stream conversion batch: {e}")
4077                })?;
4078            }
4079        } else if metadata_changed {
4080            for batch in &mut batches {
4081                *batch = RecordBatch::try_new(schema.clone(), batch.columns().to_vec()).map_err(
4082                    |e| anyhow::anyhow!("Failed to create stream conversion batch: {e}"),
4083                )?;
4084            }
4085        }
4086
4087        let mut batch = concat_batches(&schema, batches.iter())
4088            .map_err(|e| anyhow::anyhow!("Failed to concatenate stream batches: {e}"))?;
4089
4090        if batch.num_rows() == 0 {
4091            return Ok(None);
4092        }
4093
4094        if !Self::is_record_batch_monotonic_by_ts_init(&batch)? {
4095            let indices = sort_to_indices(
4096                Self::ts_init_array(&batch)?,
4097                Some(SortOptions {
4098                    descending: false,
4099                    nulls_first: false,
4100                }),
4101                None,
4102            )
4103            .map_err(|e| anyhow::anyhow!("Failed to sort stream conversion batch: {e}"))?;
4104            batch = take_record_batch(&batch, &indices)
4105                .map_err(|e| anyhow::anyhow!("Failed to reorder stream conversion batch: {e}"))?;
4106        }
4107
4108        let ts_init = Self::ts_init_array(&batch)?;
4109        if ts_init.null_count() > 0 {
4110            anyhow::bail!("ts_init column contains null values");
4111        }
4112
4113        Ok(Some(batch))
4114    }
4115
4116    fn is_record_batch_monotonic_by_ts_init(batch: &RecordBatch) -> anyhow::Result<bool> {
4117        let ts_init = Self::ts_init_array(batch)?;
4118        if ts_init.null_count() > 0 {
4119            anyhow::bail!("ts_init column contains null values");
4120        }
4121
4122        for idx in 1..ts_init.len() {
4123            if ts_init.value(idx) < ts_init.value(idx - 1) {
4124                return Ok(false);
4125            }
4126        }
4127        Ok(true)
4128    }
4129
4130    fn ts_init_range(batch: &RecordBatch) -> anyhow::Result<(u64, u64)> {
4131        let ts_init = Self::ts_init_array(batch)?;
4132        if ts_init.is_empty() {
4133            anyhow::bail!("Cannot convert empty stream batch to parquet");
4134        }
4135
4136        if ts_init.null_count() > 0 {
4137            anyhow::bail!("ts_init column contains null values");
4138        }
4139
4140        Ok((ts_init.value(0), ts_init.value(ts_init.len() - 1)))
4141    }
4142
4143    fn ts_init_array(batch: &RecordBatch) -> anyhow::Result<&UInt64Array> {
4144        let ts_init_idx = batch
4145            .schema()
4146            .index_of("ts_init")
4147            .map_err(|_| anyhow::anyhow!("ts_init column not found"))?;
4148        batch
4149            .column(ts_init_idx)
4150            .as_any()
4151            .downcast_ref::<UInt64Array>()
4152            .ok_or_else(|| anyhow::anyhow!("ts_init column is not UInt64"))
4153    }
4154
4155    fn identifier_from_batch_or_path(
4156        batch: &RecordBatch,
4157        data_name: &str,
4158        feather_path: &str,
4159    ) -> Option<String> {
4160        let metadata = batch.schema().metadata().clone();
4161        if let Some(bar_type) = metadata.get("bar_type") {
4162            return Some(bar_type.clone());
4163        }
4164
4165        if let Some(instrument_id) = metadata.get("instrument_id") {
4166            return Some(instrument_id.clone());
4167        }
4168
4169        let parts: Vec<&str> = feather_path.trim_matches('/').split('/').collect();
4170        if let Some(type_name) = data_name.strip_prefix("custom/") {
4171            return Self::custom_identifier_from_path(&parts, type_name);
4172        }
4173
4174        // Stream data currently uses .../{data_name}/{identifier}/{file}.feather.
4175        // Keep this fallback explicit because it depends on data_name being one path segment.
4176        if parts.len() >= 3 && parts[parts.len() - 3] == data_name {
4177            return Some(parts[parts.len() - 2].to_string());
4178        }
4179
4180        None
4181    }
4182
4183    fn custom_identifier_from_path(parts: &[&str], type_name: &str) -> Option<String> {
4184        let type_idx = parts
4185            .windows(2)
4186            .position(|window| window[0] == "custom" && window[1] == type_name)?
4187            + 1;
4188        let identifier_start = type_idx + 1;
4189        let file_idx = parts.len().checked_sub(1)?;
4190
4191        if identifier_start >= file_idx {
4192            return None;
4193        }
4194
4195        Some(parts[identifier_start..file_idx].join("/"))
4196    }
4197
4198    fn is_supported_stream_data_type(data_name: &str) -> bool {
4199        data_name.starts_with("custom/")
4200            || matches!(
4201                data_name,
4202                "quotes"
4203                    | "trades"
4204                    | "order_book_deltas"
4205                    | "order_book_depths"
4206                    | "bars"
4207                    | "index_prices"
4208                    | "mark_prices"
4209                    | "option_greeks"
4210                    | "instrument_status"
4211                    | "instrument_closes"
4212                    | "funding_rate_update"
4213                    | "account_state"
4214                    | "order_initialized"
4215                    | "order_denied"
4216                    | "order_emulated"
4217                    | "order_submitted"
4218                    | "order_accepted"
4219                    | "order_rejected"
4220                    | "order_pending_cancel"
4221                    | "order_canceled"
4222                    | "order_cancel_rejected"
4223                    | "order_expired"
4224                    | "order_triggered"
4225                    | "order_pending_update"
4226                    | "order_released"
4227                    | "order_modify_rejected"
4228                    | "order_updated"
4229                    | "order_filled"
4230                    | "position_opened"
4231                    | "position_changed"
4232                    | "position_closed"
4233                    | "position_adjusted"
4234                    | "order_snapshot"
4235                    | "position_snapshot"
4236                    | "order_status_report"
4237                    | "fill_report"
4238                    | "position_status_report"
4239                    | "execution_mass_status"
4240            )
4241    }
4242}
4243
4244/// Trait for providing catalog path prefixes for different data types.
4245///
4246/// This trait enables type-safe organization of data within the catalog by providing
4247/// a standardized way to determine the directory structure for each data type.
4248/// Each data type maps to a specific subdirectory within the catalog's data folder.
4249///
4250/// # Implementation
4251///
4252/// Types implementing this trait should return a static string that represents
4253/// the directory name where data of that type should be stored.
4254///
4255/// # Examples
4256///
4257/// ```rust
4258/// use nautilus_model::data::QuoteTick;
4259/// use nautilus_persistence::backend::catalog::CatalogPathPrefix;
4260///
4261/// assert_eq!(QuoteTick::path_prefix(), "quotes");
4262/// ```
4263pub trait CatalogPathPrefix {
4264    /// Returns the path prefix (directory name) for this data type.
4265    ///
4266    /// # Returns
4267    ///
4268    /// A static string representing the directory name where this data type is stored.
4269    fn path_prefix() -> &'static str;
4270}
4271
4272/// Macro for implementing [`CatalogPathPrefix`] for data types.
4273///
4274/// This macro provides a convenient way to implement the trait for multiple types
4275/// with their corresponding path prefixes.
4276///
4277/// # Parameters
4278///
4279/// - `$type`: The data type to implement the trait for.
4280/// - `$path`: The path prefix string for that type.
4281macro_rules! impl_catalog_path_prefix {
4282    ($type:ty, $path:expr) => {
4283        impl CatalogPathPrefix for $type {
4284            fn path_prefix() -> &'static str {
4285                $path
4286            }
4287        }
4288    };
4289}
4290
4291// Standard implementations for financial data types
4292impl_catalog_path_prefix!(QuoteTick, "quotes");
4293impl_catalog_path_prefix!(TradeTick, "trades");
4294impl_catalog_path_prefix!(OrderBookDelta, "order_book_deltas");
4295impl_catalog_path_prefix!(OrderBookDepth10, "order_book_depths");
4296impl_catalog_path_prefix!(Bar, "bars");
4297impl_catalog_path_prefix!(IndexPriceUpdate, "index_prices");
4298impl_catalog_path_prefix!(MarkPriceUpdate, "mark_prices");
4299impl_catalog_path_prefix!(FundingRateUpdate, "funding_rate_update");
4300impl_catalog_path_prefix!(OptionGreeks, "option_greeks");
4301impl_catalog_path_prefix!(InstrumentStatus, "instrument_status");
4302impl_catalog_path_prefix!(InstrumentClose, "instrument_closes");
4303impl_catalog_path_prefix!(InstrumentAny, "instruments");
4304impl_catalog_path_prefix!(AccountState, "account_state");
4305impl_catalog_path_prefix!(OrderInitialized, "order_initialized");
4306impl_catalog_path_prefix!(OrderDenied, "order_denied");
4307impl_catalog_path_prefix!(OrderEmulated, "order_emulated");
4308impl_catalog_path_prefix!(OrderSubmitted, "order_submitted");
4309impl_catalog_path_prefix!(OrderAccepted, "order_accepted");
4310impl_catalog_path_prefix!(OrderRejected, "order_rejected");
4311impl_catalog_path_prefix!(OrderPendingCancel, "order_pending_cancel");
4312impl_catalog_path_prefix!(OrderCanceled, "order_canceled");
4313impl_catalog_path_prefix!(OrderCancelRejected, "order_cancel_rejected");
4314impl_catalog_path_prefix!(OrderExpired, "order_expired");
4315impl_catalog_path_prefix!(OrderTriggered, "order_triggered");
4316impl_catalog_path_prefix!(OrderPendingUpdate, "order_pending_update");
4317impl_catalog_path_prefix!(OrderReleased, "order_released");
4318impl_catalog_path_prefix!(OrderModifyRejected, "order_modify_rejected");
4319impl_catalog_path_prefix!(OrderUpdated, "order_updated");
4320impl_catalog_path_prefix!(OrderFilled, "order_filled");
4321impl_catalog_path_prefix!(OrderFillVoided, "order_fill_voided");
4322impl_catalog_path_prefix!(PositionOpened, "position_opened");
4323impl_catalog_path_prefix!(PositionChanged, "position_changed");
4324impl_catalog_path_prefix!(PositionClosed, "position_closed");
4325impl_catalog_path_prefix!(PositionAdjusted, "position_adjusted");
4326impl_catalog_path_prefix!(OrderSnapshot, "order_snapshot");
4327impl_catalog_path_prefix!(PositionSnapshot, "position_snapshot");
4328impl_catalog_path_prefix!(OrderStatusReport, "order_status_report");
4329impl_catalog_path_prefix!(FillReport, "fill_report");
4330impl_catalog_path_prefix!(PositionStatusReport, "position_status_report");
4331impl_catalog_path_prefix!(ExecutionMassStatus, "execution_mass_status");
4332
4333/// Converts timestamps to a filename using ISO 8601 format.
4334///
4335/// This function converts two Unix nanosecond timestamps to a filename that uses
4336/// ISO 8601 format with filesystem-safe characters. The format matches the Python
4337/// implementation for consistency.
4338///
4339/// # Parameters
4340///
4341/// - `timestamp_1`: First timestamp in Unix nanoseconds.
4342/// - `timestamp_2`: Second timestamp in Unix nanoseconds.
4343///
4344/// # Returns
4345///
4346/// Returns a filename string in the format: "`iso_timestamp_1_iso_timestamp_2.parquet`".
4347///
4348/// # Examples
4349///
4350/// ```rust
4351/// # use nautilus_persistence::backend::catalog::timestamps_to_filename;
4352/// # use nautilus_core::UnixNanos;
4353/// let filename = timestamps_to_filename(
4354///     UnixNanos::from(1609459200000000000),
4355///     UnixNanos::from(1609545600000000000)
4356/// );
4357/// // Returns something like: "2021-01-01T00-00-00-000000000Z_2021-01-02T00-00-00-000000000Z.parquet"
4358/// ```
4359// Rewrites internally aggregated bar_type metadata to the standard EXTERNAL form by
4360// parsing and rebuilding the bar type: string replacement would corrupt symbols
4361// containing "-INTERNAL" and mishandle composite suffixes. Returns whether the
4362// metadata changed.
4363fn convert_bar_type_metadata_to_external(metadata: &mut HashMap<String, String>) -> bool {
4364    let Some(bar_type_str) = metadata.get("bar_type").cloned() else {
4365        return false;
4366    };
4367
4368    let bar_type = match bar_type_str.parse::<BarType>() {
4369        Ok(bar_type) => bar_type,
4370        Err(e) => {
4371            log::warn!("Cannot convert bar_type '{bar_type_str}' to EXTERNAL: {e}");
4372            return false;
4373        }
4374    };
4375
4376    if bar_type.standard().is_externally_aggregated() {
4377        return false;
4378    }
4379
4380    // The composite chain describes internal derivation, so the converted
4381    // (venue-equivalent) form is the standard bar type marked EXTERNAL
4382    let standard = bar_type.standard();
4383    let converted = BarType::new(
4384        standard.instrument_id(),
4385        standard.spec(),
4386        AggregationSource::External,
4387    );
4388    metadata.insert("bar_type".to_string(), converted.to_string());
4389
4390    true
4391}
4392
4393#[must_use]
4394pub fn timestamps_to_filename(timestamp_1: UnixNanos, timestamp_2: UnixNanos) -> String {
4395    let datetime_1 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_1));
4396    let datetime_2 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_2));
4397
4398    format!("{datetime_1}_{datetime_2}.parquet")
4399}
4400
4401/// Converts an ISO 8601 timestamp to a filesystem-safe format.
4402///
4403/// This function replaces colons and dots with hyphens to make the timestamp
4404/// safe for use in filenames across different filesystems.
4405///
4406/// # Parameters
4407///
4408/// - `iso_timestamp`: ISO 8601 timestamp string (e.g., "2023-10-26T07:30:50.123456789Z").
4409///
4410/// # Returns
4411///
4412/// Returns a filesystem-safe timestamp string (e.g., "2023-10-26T07-30-50-123456789Z").
4413fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
4414    iso_timestamp.replace([':', '.'], "-")
4415}
4416
4417/// Converts a filesystem-safe timestamp back to ISO 8601 format.
4418///
4419/// This function reverses the transformation done by `iso_timestamp_to_file_timestamp`,
4420/// converting filesystem-safe timestamps back to standard ISO 8601 format.
4421///
4422/// # Parameters
4423///
4424/// - `file_timestamp`: Filesystem-safe timestamp string (e.g., "2023-10-26T07-30-50-123456789Z").
4425///
4426/// # Returns
4427///
4428/// Returns an ISO 8601 timestamp string (e.g., "2023-10-26T07:30:50.123456789Z").
4429fn file_timestamp_to_iso_timestamp(file_timestamp: &str) -> String {
4430    let (date_part, time_part) = file_timestamp
4431        .split_once('T')
4432        .unwrap_or((file_timestamp, ""));
4433    let time_part = time_part.strip_suffix('Z').unwrap_or(time_part);
4434
4435    // Find the last hyphen to separate nanoseconds
4436    if let Some(last_hyphen_idx) = time_part.rfind('-') {
4437        let time_with_dot_for_nanos = format!(
4438            "{}.{}",
4439            &time_part[..last_hyphen_idx],
4440            &time_part[last_hyphen_idx + 1..]
4441        );
4442        let final_time_part = time_with_dot_for_nanos.replace('-', ":");
4443        format!("{date_part}T{final_time_part}Z")
4444    } else {
4445        // Fallback if no nanoseconds part found
4446        let final_time_part = time_part.replace('-', ":");
4447        format!("{date_part}T{final_time_part}Z")
4448    }
4449}
4450
4451/// Converts an ISO 8601 timestamp string to Unix nanoseconds.
4452///
4453/// This function parses an ISO 8601 timestamp and converts it to Unix nanoseconds.
4454/// It's used to convert parsed timestamps back to the internal representation.
4455///
4456/// # Parameters
4457///
4458/// - `iso_timestamp`: ISO 8601 timestamp string (e.g., "2023-10-26T07:30:50.123456789Z").
4459///
4460/// # Returns
4461///
4462/// Returns `Ok(u64)` with the Unix nanoseconds timestamp, or an error if parsing fails.
4463fn iso_to_unix_nanos(iso_timestamp: &str) -> anyhow::Result<u64> {
4464    Ok(iso8601_to_unix_nanos(iso_timestamp)?.into())
4465}
4466
4467/// Converts an instrument ID to a URI-safe format by removing forward slashes
4468/// and replacing carets with underscores.
4469///
4470/// Some instrument IDs contain forward slashes (e.g., "BTC/USD") which are not
4471/// suitable for use in file paths. This function transforms these characters to
4472/// create a safe directory name.
4473///
4474/// # Parameters
4475///
4476/// - `instrument_id`: The original instrument ID string.
4477///
4478/// # Returns
4479///
4480/// A URI-safe version of the instrument ID with forward slashes removed and carets replaced.
4481///
4482/// # Examples
4483///
4484/// ```rust
4485/// # use nautilus_persistence::backend::catalog::urisafe_instrument_id;
4486/// assert_eq!(urisafe_instrument_id("BTC/USD"), "BTCUSD");
4487/// assert_eq!(urisafe_instrument_id("EUR-USD"), "EUR-USD");
4488/// assert_eq!(urisafe_instrument_id("^SPX.CBOE"), "_SPX.CBOE");
4489/// ```
4490#[must_use]
4491pub fn urisafe_instrument_id(instrument_id: &str) -> String {
4492    instrument_id.replace('/', "").replace('^', "_")
4493}
4494
4495// Extract the instrument ID portion from a bar type directory name.
4496// Handles both standard and composite formats:
4497//   {id}-{step}-{agg}-{price}-{source}
4498//   {id}-{step}-{agg}-{price}-{source}@{step}-{agg}-{source}
4499// Strips the composite suffix before parsing with rsplitn(5, '-').
4500fn extract_bar_type_instrument_id(bar_type_dir: &str) -> Option<&str> {
4501    let standard = bar_type_dir.split('@').next().unwrap_or(bar_type_dir);
4502    let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
4503    // pieces (reversed): [source, price_type, agg, step, instrument_id]
4504    if pieces.len() == 5 && pieces[3].chars().all(|c| c.is_ascii_digit()) {
4505        Some(pieces[4])
4506    } else {
4507        None
4508    }
4509}
4510
4511/// Normalizes a custom data identifier for use in directory paths.
4512/// Replaces `//` with `/`, and filters out empty segments and `..` to prevent path traversal.
4513#[must_use]
4514pub fn safe_directory_identifier(identifier: &str) -> String {
4515    let normalized = identifier.replace("//", "/");
4516    let segments: Vec<&str> = normalized
4517        .split('/')
4518        .filter(|s| !s.is_empty() && *s != "..")
4519        .collect();
4520    segments.join("/")
4521}
4522
4523/// Extracts the identifier from a file path.
4524///
4525/// The identifier is typically the second-to-last path component (directory name).
4526/// For example, from "`data/quote_tick/EURUSD/file.parquet`", extracts "EURUSD".
4527#[must_use]
4528pub fn extract_identifier_from_path(file_path: &str) -> String {
4529    let path_parts: Vec<&str> = file_path.split('/').collect();
4530    if path_parts.len() >= 2 {
4531        path_parts[path_parts.len() - 2].to_string()
4532    } else {
4533        "unknown".to_string()
4534    }
4535}
4536
4537/// Makes an identifier safe for use in SQL table names.
4538///
4539/// Keeps ASCII alphanumerics and underscores; replaces everything else with `_`, then lowercases.
4540#[must_use]
4541pub fn make_sql_safe_identifier(identifier: &str) -> String {
4542    urisafe_instrument_id(identifier)
4543        .chars()
4544        .map(|c| {
4545            if c.is_ascii_alphanumeric() {
4546                c.to_ascii_lowercase()
4547            } else {
4548                '_'
4549            }
4550        })
4551        .collect()
4552}
4553
4554/// Extracts the filename from a file path and makes it SQL-safe.
4555///
4556/// For example, from "data/quote_tick/EURUSD/2021-01-01T00-00-00-000000000Z_2021-01-02T00-00-00-000000000Z.parquet",
4557/// extracts "`2021_01_01t00_00_00_000000000z_2021_01_02t00_00_00_000000000z`".
4558#[must_use]
4559pub fn extract_sql_safe_filename(file_path: &str) -> String {
4560    if file_path.is_empty() {
4561        return "unknown_file".to_string();
4562    }
4563
4564    let filename = file_path.split('/').next_back().unwrap_or("unknown_file");
4565
4566    // Remove .parquet extension
4567    let name_without_ext = if let Some(dot_pos) = filename.rfind(".parquet") {
4568        &filename[..dot_pos]
4569    } else {
4570        filename
4571    };
4572
4573    // Remove characters that can pose problems: hyphens, colons, etc.
4574    name_without_ext
4575        .replace(['-', ':', '.'], "_")
4576        .to_lowercase()
4577}
4578
4579/// Creates a platform-appropriate local path using `PathBuf`.
4580///
4581/// This function constructs file system paths using the platform's native path separators.
4582/// Use this for local file operations that need to work with the actual file system.
4583///
4584/// # Arguments
4585///
4586/// - `base_path` - The base directory path
4587/// - `components` - Path components to join
4588///
4589/// # Returns
4590///
4591/// A `PathBuf` with platform-appropriate separators
4592///
4593/// # Examples
4594///
4595/// ```rust
4596/// # use nautilus_persistence::backend::catalog::make_local_path;
4597/// let path = make_local_path("/base", &["data", "quotes", "EURUSD"]);
4598/// // On Unix: "/base/data/quotes/EURUSD"
4599/// // On Windows: "\base\data\quotes\EURUSD"
4600/// ```
4601pub fn make_local_path<P: AsRef<Path>>(base_path: P, components: &[&str]) -> PathBuf {
4602    let mut path = PathBuf::from(base_path.as_ref());
4603    for component in components {
4604        path.push(component);
4605    }
4606    path
4607}
4608
4609/// Creates an object store path using forward slashes.
4610///
4611/// Object stores (S3, GCS, etc.) always expect forward slashes regardless of platform.
4612/// Use this when creating paths for object store operations.
4613///
4614/// # Arguments
4615///
4616/// - `base_path` - The base path (can be empty)
4617/// - `components` - Path components to join
4618///
4619/// # Returns
4620///
4621/// A string path with forward slash separators
4622///
4623/// # Examples
4624///
4625/// ```rust
4626/// # use nautilus_persistence::backend::catalog::make_object_store_path;
4627/// let path = make_object_store_path("base", &["data", "quotes", "EURUSD"]);
4628/// assert_eq!(path, "base/data/quotes/EURUSD");
4629/// ```
4630#[must_use]
4631pub fn make_object_store_path(base_path: &str, components: &[&str]) -> String {
4632    let mut parts = Vec::new();
4633
4634    if !base_path.is_empty() {
4635        let normalized_base = base_path
4636            .replace('\\', "/")
4637            .trim_end_matches('/')
4638            .to_string();
4639
4640        if !normalized_base.is_empty() {
4641            parts.push(normalized_base);
4642        }
4643    }
4644
4645    for component in components {
4646        let normalized_component = component
4647            .replace('\\', "/")
4648            .trim_start_matches('/')
4649            .trim_end_matches('/')
4650            .to_string();
4651
4652        if !normalized_component.is_empty() {
4653            parts.push(normalized_component);
4654        }
4655    }
4656
4657    parts.join("/")
4658}
4659
4660/// Creates an object store path using forward slashes with owned strings.
4661///
4662/// This variant accepts owned strings to avoid lifetime issues.
4663///
4664/// # Arguments
4665///
4666/// - `base_path` - The base path (can be empty)
4667/// - `components` - Path components to join (owned strings)
4668///
4669/// # Returns
4670///
4671/// A string path with forward slash separators
4672#[must_use]
4673pub fn make_object_store_path_owned(base_path: &str, components: Vec<String>) -> String {
4674    let mut parts = Vec::new();
4675
4676    if !base_path.is_empty() {
4677        let normalized_base = base_path
4678            .replace('\\', "/")
4679            .trim_end_matches('/')
4680            .to_string();
4681
4682        if !normalized_base.is_empty() {
4683            parts.push(normalized_base);
4684        }
4685    }
4686
4687    for component in components {
4688        let normalized_component = component
4689            .replace('\\', "/")
4690            .trim_start_matches('/')
4691            .trim_end_matches('/')
4692            .to_string();
4693
4694        if !normalized_component.is_empty() {
4695            parts.push(normalized_component);
4696        }
4697    }
4698
4699    parts.join("/")
4700}
4701
4702/// Converts a local `PathBuf` to an object store path string.
4703///
4704/// This function normalizes a local file system path to the forward-slash format
4705/// expected by object stores, handling platform differences.
4706///
4707/// # Arguments
4708///
4709/// - `local_path` - The local `PathBuf` to convert
4710///
4711/// # Returns
4712///
4713/// A string with forward slash separators suitable for object store operations
4714///
4715/// # Examples
4716///
4717/// ```rust
4718/// # use std::path::PathBuf;
4719/// # use nautilus_persistence::backend::catalog::local_to_object_store_path;
4720/// let local_path = PathBuf::from("data").join("quotes").join("EURUSD");
4721/// let object_path = local_to_object_store_path(&local_path);
4722/// assert_eq!(object_path, "data/quotes/EURUSD");
4723/// ```
4724#[must_use]
4725pub fn local_to_object_store_path(local_path: &Path) -> String {
4726    local_path.to_string_lossy().replace('\\', "/")
4727}
4728
4729/// Extracts path components using platform-appropriate path parsing.
4730///
4731/// This function safely parses a path into its components, handling both
4732/// local file system paths and object store paths correctly.
4733///
4734/// # Arguments
4735///
4736/// - `path_str` - The path string to parse
4737///
4738/// # Returns
4739///
4740/// A vector of path components
4741///
4742/// # Examples
4743///
4744/// ```rust
4745/// # use nautilus_persistence::backend::catalog::extract_path_components;
4746/// let components = extract_path_components("data/quotes/EURUSD");
4747/// assert_eq!(components, vec!["data", "quotes", "EURUSD"]);
4748///
4749/// // Works with both separators
4750/// let components = extract_path_components("data\\quotes\\EURUSD");
4751/// assert_eq!(components, vec!["data", "quotes", "EURUSD"]);
4752/// ```
4753#[must_use]
4754pub fn extract_path_components(path_str: &str) -> Vec<String> {
4755    // Normalize separators and split
4756    let normalized = path_str.replace('\\', "/");
4757    normalized
4758        .split('/')
4759        .filter(|s| !s.is_empty())
4760        .map(ToString::to_string)
4761        .collect()
4762}
4763
4764/// Checks if a filename's timestamp range intersects with a query interval.
4765///
4766/// This function determines whether a Parquet file (identified by its timestamp-based
4767/// filename) contains data that falls within the specified query time range.
4768///
4769/// # Parameters
4770///
4771/// - `filename`: The filename to check (format: "`iso_timestamp_1_iso_timestamp_2.parquet`").
4772/// - `start`: Optional start timestamp for the query range.
4773/// - `end`: Optional end timestamp for the query range.
4774///
4775/// # Returns
4776///
4777/// Returns `true` if the file's time range intersects with the query range,
4778/// `false` otherwise. Returns `true` if the filename cannot be parsed.
4779fn query_intersects_filename(filename: &str, start: Option<u64>, end: Option<u64>) -> bool {
4780    if let Some((file_start, file_end)) = parse_filename_timestamps(filename) {
4781        (start.is_none() || start.unwrap() <= file_end)
4782            && (end.is_none() || file_start <= end.unwrap())
4783    } else {
4784        true
4785    }
4786}
4787
4788/// Parses timestamps from a Parquet filename.
4789///
4790/// Extracts the start and end timestamps from filenames that follow the ISO 8601 format:
4791/// "`iso_timestamp_1_iso_timestamp_2.parquet`" (e.g., "2021-01-01T00-00-00-000000000Z_2021-01-02T00-00-00-000000000Z.parquet")
4792///
4793/// # Parameters
4794///
4795/// - `filename`: The filename to parse (can be a full path).
4796///
4797/// # Returns
4798///
4799/// Returns `Some((start_ts, end_ts))` if the filename matches the expected format,
4800/// `None` otherwise.
4801///
4802/// # Examples
4803///
4804/// ```rust
4805/// # use nautilus_persistence::backend::catalog::parse_filename_timestamps;
4806/// assert!(
4807///     parse_filename_timestamps(
4808///         "2021-01-01T00-00-00-000000000Z_2021-01-02T00-00-00-000000000Z.parquet"
4809///     )
4810///     .is_some()
4811/// );
4812/// assert_eq!(parse_filename_timestamps("invalid.parquet"), None);
4813/// ```
4814#[must_use]
4815pub fn parse_filename_timestamps(filename: &str) -> Option<(u64, u64)> {
4816    let path = Path::new(filename);
4817    let base_name = path.file_name()?.to_str()?;
4818    let base_filename = base_name.strip_suffix(".parquet")?;
4819    let (first_part, second_part) = base_filename.split_once('_')?;
4820
4821    let first_iso = file_timestamp_to_iso_timestamp(first_part);
4822    let second_iso = file_timestamp_to_iso_timestamp(second_part);
4823
4824    let first_ts = iso_to_unix_nanos(&first_iso).ok()?;
4825    let second_ts = iso_to_unix_nanos(&second_iso).ok()?;
4826
4827    Some((first_ts, second_ts))
4828}
4829
4830/// Checks if a list of closed integer intervals are all mutually disjoint.
4831///
4832/// Two intervals are disjoint if they do not overlap. This function validates that
4833/// all intervals in the list are non-overlapping, which is a requirement for
4834/// maintaining data integrity in the catalog.
4835///
4836/// # Parameters
4837///
4838/// - `intervals`: A slice of timestamp intervals as (start, end) tuples.
4839///
4840/// # Returns
4841///
4842/// Returns `true` if all intervals are disjoint, `false` if any overlap is found.
4843/// Returns `true` for empty lists or lists with a single interval.
4844///
4845/// # Examples
4846///
4847/// ```rust
4848/// # use nautilus_persistence::backend::catalog::are_intervals_disjoint;
4849/// // Disjoint intervals
4850/// assert!(are_intervals_disjoint(&[(1, 5), (10, 15), (20, 25)]));
4851///
4852/// // Overlapping intervals
4853/// assert!(!are_intervals_disjoint(&[(1, 10), (5, 15)]));
4854/// ```
4855#[must_use]
4856pub fn are_intervals_disjoint(intervals: &[(u64, u64)]) -> bool {
4857    let n = intervals.len();
4858
4859    if n <= 1 {
4860        return true;
4861    }
4862
4863    let mut sorted_intervals: Vec<(u64, u64)> = intervals.to_vec();
4864    sorted_intervals.sort_by_key(|&(start, _)| start);
4865
4866    for i in 0..(n - 1) {
4867        let (_, end1) = sorted_intervals[i];
4868        let (start2, _) = sorted_intervals[i + 1];
4869
4870        if end1 >= start2 {
4871            return false;
4872        }
4873    }
4874
4875    true
4876}
4877
4878/// Checks if intervals are contiguous (adjacent with no gaps).
4879///
4880/// Intervals are contiguous if, when sorted by start time, each interval's start
4881/// timestamp is exactly one more than the previous interval's end timestamp.
4882/// This ensures complete coverage of a time range with no gaps.
4883///
4884/// # Parameters
4885///
4886/// - `intervals`: A slice of timestamp intervals as (start, end) tuples.
4887///
4888/// # Returns
4889///
4890/// Returns `true` if all intervals are contiguous, `false` if any gaps are found.
4891/// Returns `true` for empty lists or lists with a single interval.
4892///
4893/// # Examples
4894///
4895/// ```rust
4896/// # use nautilus_persistence::backend::catalog::are_intervals_contiguous;
4897/// // Contiguous intervals
4898/// assert!(are_intervals_contiguous(&[(1, 5), (6, 10), (11, 15)]));
4899///
4900/// // Non-contiguous intervals (gap between 5 and 8)
4901/// assert!(!are_intervals_contiguous(&[(1, 5), (8, 10)]));
4902/// ```
4903#[must_use]
4904pub fn are_intervals_contiguous(intervals: &[(u64, u64)]) -> bool {
4905    let n = intervals.len();
4906    if n <= 1 {
4907        return true;
4908    }
4909
4910    let mut sorted_intervals: Vec<(u64, u64)> = intervals.to_vec();
4911    sorted_intervals.sort_by_key(|&(start, _)| start);
4912
4913    for i in 0..(n - 1) {
4914        let (_, end1) = sorted_intervals[i];
4915        let (start2, _) = sorted_intervals[i + 1];
4916
4917        if end1 + 1 != start2 {
4918            return false;
4919        }
4920    }
4921
4922    true
4923}
4924
4925/// Finds the parts of a query interval that are not covered by existing data intervals.
4926///
4927/// This function calculates the "gaps" in data coverage by comparing a requested
4928/// time range against the intervals covered by existing data files. It's used to
4929/// determine what data needs to be fetched or backfilled.
4930///
4931/// # Parameters
4932///
4933/// - `start`: Start timestamp of the query interval (inclusive).
4934/// - `end`: End timestamp of the query interval (inclusive).
4935/// - `closed_intervals`: Existing data intervals as (start, end) tuples.
4936///
4937/// # Returns
4938///
4939/// Returns a vector of (start, end) tuples representing the gaps in coverage.
4940/// Returns an empty vector if the query range is invalid or fully covered.
4941fn query_interval_diff(start: u64, end: u64, closed_intervals: &[(u64, u64)]) -> Vec<(u64, u64)> {
4942    if start > end {
4943        return Vec::new();
4944    }
4945
4946    let interval_set = get_interval_set(closed_intervals);
4947    let query_range = (RangeBound::Included(start), RangeBound::Included(end));
4948    let query_diff = interval_set.get_interval_difference(&query_range);
4949    let mut result: Vec<(u64, u64)> = Vec::new();
4950
4951    for interval in query_diff {
4952        if let Some(tuple) = interval_to_tuple(interval, start, end) {
4953            result.push(tuple);
4954        }
4955    }
4956
4957    result
4958}
4959
4960/// Creates an interval tree from closed integer intervals.
4961///
4962/// This function converts closed intervals [a, b] into half-open intervals [a, b+1)
4963/// for use with the interval tree data structure, which is used for efficient
4964/// interval operations and gap detection.
4965///
4966/// # Parameters
4967///
4968/// - `intervals`: A slice of closed intervals as (start, end) tuples.
4969///
4970/// # Returns
4971///
4972/// Returns an [`IntervalTree`] containing the converted intervals.
4973///
4974/// # Notes
4975///
4976/// - Invalid intervals (where start > end) are skipped.
4977/// - Uses saturating addition to prevent overflow when converting to half-open intervals.
4978fn get_interval_set(intervals: &[(u64, u64)]) -> IntervalTree<u64> {
4979    let mut tree = IntervalTree::default();
4980
4981    if intervals.is_empty() {
4982        return tree;
4983    }
4984
4985    for &(start, end) in intervals {
4986        if start > end {
4987            continue;
4988        }
4989
4990        tree.insert((
4991            RangeBound::Included(start),
4992            RangeBound::Excluded(end.saturating_add(1)),
4993        ));
4994    }
4995
4996    tree
4997}
4998
4999/// Converts an interval tree result back to a closed interval tuple.
5000///
5001/// This helper function converts the bounded interval representation used by
5002/// the interval tree back into the (start, end) tuple format used throughout
5003/// the catalog.
5004///
5005/// # Parameters
5006///
5007/// - `interval`: The bounded interval from the interval tree.
5008/// - `query_start`: The start of the original query range.
5009/// - `query_end`: The end of the original query range.
5010///
5011/// # Returns
5012///
5013/// Returns `Some((start, end))` for valid intervals, `None` for empty intervals.
5014fn interval_to_tuple(
5015    interval: (RangeBound<&u64>, RangeBound<&u64>),
5016    query_start: u64,
5017    query_end: u64,
5018) -> Option<(u64, u64)> {
5019    let (bound_start, bound_end) = interval;
5020
5021    let start = match bound_start {
5022        RangeBound::Included(val) => *val,
5023        RangeBound::Excluded(val) => val.saturating_add(1),
5024        RangeBound::Unbounded => query_start,
5025    };
5026
5027    let end = match bound_end {
5028        RangeBound::Included(val) => *val,
5029        RangeBound::Excluded(val) => {
5030            if *val == 0 {
5031                return None; // Empty interval
5032            }
5033            val - 1
5034        }
5035        RangeBound::Unbounded => query_end,
5036    };
5037
5038    if start <= end {
5039        Some((start, end))
5040    } else {
5041        None
5042    }
5043}
5044
5045#[cfg(test)]
5046mod tests {
5047    use rstest::rstest;
5048
5049    use super::*;
5050
5051    fn metadata_with(bar_type: &str) -> HashMap<String, String> {
5052        HashMap::from([("bar_type".to_string(), bar_type.to_string())])
5053    }
5054
5055    #[rstest]
5056    #[case::internal_converts(
5057        "AUD/USD.SIM-1-MINUTE-LAST-INTERNAL",
5058        Some("AUD/USD.SIM-1-MINUTE-LAST-EXTERNAL")
5059    )]
5060    #[case::external_unchanged("AUD/USD.SIM-1-MINUTE-LAST-EXTERNAL", None)]
5061    #[case::composite_flattens(
5062        "AUD/USD.SIM-5-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
5063        Some("AUD/USD.SIM-5-MINUTE-LAST-EXTERNAL")
5064    )]
5065    #[case::internal_in_symbol_preserved(
5066        "X-INTERNAL.SIM-1-MINUTE-LAST-INTERNAL",
5067        Some("X-INTERNAL.SIM-1-MINUTE-LAST-EXTERNAL")
5068    )]
5069    #[case::unparsable_unchanged("not-a-bar-type-INTERNAL", None)]
5070    fn test_convert_bar_type_metadata_to_external(
5071        #[case] input: &str,
5072        #[case] expected: Option<&str>,
5073    ) {
5074        let mut metadata = metadata_with(input);
5075
5076        let changed = convert_bar_type_metadata_to_external(&mut metadata);
5077
5078        assert_eq!(changed, expected.is_some());
5079        assert_eq!(
5080            metadata.get("bar_type").map(String::as_str),
5081            Some(expected.unwrap_or(input)),
5082        );
5083    }
5084
5085    #[rstest]
5086    fn test_convert_bar_type_metadata_without_key_is_noop() {
5087        let mut metadata = HashMap::new();
5088
5089        assert!(!convert_bar_type_metadata_to_external(&mut metadata));
5090        assert!(metadata.is_empty());
5091    }
5092
5093    #[rstest]
5094    fn test_iso_timestamp_to_file_timestamp() {
5095        assert_eq!(
5096            iso_timestamp_to_file_timestamp("2023-10-26T07:30:50.123456789Z"),
5097            "2023-10-26T07-30-50-123456789Z"
5098        );
5099    }
5100
5101    #[rstest]
5102    fn test_file_timestamp_to_iso_timestamp() {
5103        assert_eq!(
5104            file_timestamp_to_iso_timestamp("2023-10-26T07-30-50-123456789Z"),
5105            "2023-10-26T07:30:50.123456789Z"
5106        );
5107    }
5108
5109    #[rstest]
5110    fn test_iso_to_unix_nanos() {
5111        assert_eq!(
5112            iso_to_unix_nanos("2021-01-01T00:00:00.000000000Z").unwrap(),
5113            1_609_459_200_000_000_000
5114        );
5115    }
5116
5117    #[rstest]
5118    fn test_query_intersects_filename() {
5119        assert!(query_intersects_filename(
5120            "2021-01-01T00-00-00-000000000Z_2021-01-02T00-00-00-000000000Z.parquet",
5121            Some(1_609_459_200_000_000_000),
5122            Some(1_609_545_600_000_000_000)
5123        ));
5124    }
5125
5126    #[rstest]
5127    fn test_query_interval_diff() {
5128        assert_eq!(
5129            query_interval_diff(1, 100, &[(10, 30), (60, 80)]),
5130            vec![(1, 9), (31, 59), (81, 100)]
5131        );
5132    }
5133}