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