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("es, 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("es, 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}