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