Skip to main content

nautilus_persistence/python/
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
16use std::collections::HashMap;
17
18use nautilus_core::{UnixNanos, python::to_pytype_err};
19use nautilus_model::{
20    data::{
21        Bar, Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, OptionGreeks,
22        OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick, close::InstrumentClose,
23    },
24    python::{
25        data::data_to_pyobject,
26        instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
27    },
28};
29use pyo3::{exceptions::PyIOError, prelude::*, types::PyList};
30
31use crate::backend::catalog::ParquetDataCatalog;
32
33/// A catalog for writing data to Parquet files.
34#[pyclass(name = "ParquetDataCatalog", module = "nautilus_trader.persistence")]
35#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")]
36pub struct PyParquetDataCatalog {
37    inner: ParquetDataCatalog,
38}
39
40#[pymethods]
41#[pyo3_stub_gen::derive::gen_stub_pymethods]
42impl PyParquetDataCatalog {
43    /// Create a new `ParquetCatalog` with the given base path and optional parameters.
44    ///
45    /// # Parameters
46    ///
47    /// - `base_path`: The base path for the catalog
48    /// - `storage_options`: Optional storage configuration for cloud backends
49    /// - `batch_size`: Optional batch size for processing (default: 5000)
50    /// - `compression`: Optional compression type (0=UNCOMPRESSED, 1=SNAPPY, 2=GZIP, 3=LZO, 4=BROTLI, 5=LZ4, 6=ZSTD)
51    /// - `max_row_group_size`: Optional maximum row group size (default: 5000)
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the underlying [`ParquetDataCatalog`] cannot be created.
56    #[new]
57    #[pyo3(signature = (base_path, storage_options=None, batch_size=None, compression=None, max_row_group_size=None))]
58    pub fn py_new(
59        base_path: &str,
60        storage_options: Option<HashMap<String, String>>,
61        batch_size: Option<usize>,
62        compression: Option<u8>,
63        max_row_group_size: Option<usize>,
64    ) -> PyResult<Self> {
65        let compression = compression.map(|c| match c {
66            0 => parquet::basic::Compression::UNCOMPRESSED,
67            // For GZIP, LZO, BROTLI, LZ4, ZSTD we need to use the default level
68            // since we can't pass the level parameter through PyO3
69            2 => {
70                let level = parquet::basic::GzipLevel::default();
71                parquet::basic::Compression::GZIP(level)
72            }
73            3 => parquet::basic::Compression::LZO,
74            4 => {
75                let level = parquet::basic::BrotliLevel::default();
76                parquet::basic::Compression::BROTLI(level)
77            }
78            5 => parquet::basic::Compression::LZ4,
79            6 => {
80                let level = parquet::basic::ZstdLevel::default();
81                parquet::basic::Compression::ZSTD(level)
82            }
83            _ => parquet::basic::Compression::SNAPPY,
84        });
85
86        // Convert HashMap to AHashMap for internal use
87        let storage_options = storage_options.map(|m| m.into_iter().collect());
88
89        Ok(Self {
90            inner: ParquetDataCatalog::from_uri(
91                base_path,
92                storage_options,
93                batch_size,
94                compression,
95                max_row_group_size,
96            )
97            .map_err(|e| PyIOError::new_err(format!("Failed to create ParquetDataCatalog: {e}")))?,
98        })
99    }
100
101    // TODO: Cannot pass mixed data across pyo3 as a single type
102    // pub fn write_data(mut slf: PyRefMut<'_, Self>, data_type: NautilusDataType, data: Vec<Data>) {}
103
104    /// Write quote tick data to Parquet files.
105    ///
106    /// # Parameters
107    ///
108    /// - `data`: Vector of quote ticks to write
109    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
110    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
111    ///
112    /// # Returns
113    ///
114    /// Returns the path of the created file as a string.
115    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
116    pub fn write_quote_ticks(
117        &self,
118        data: Vec<QuoteTick>,
119        start: Option<u64>,
120        end: Option<u64>,
121        skip_disjoint_check: bool,
122    ) -> PyResult<String> {
123        let start_nanos = start.map(UnixNanos::from);
124        let end_nanos = end.map(UnixNanos::from);
125        let data = data.into_boxed_slice();
126
127        self.inner
128            .write_to_parquet(
129                data.as_ref(),
130                start_nanos,
131                end_nanos,
132                Some(skip_disjoint_check),
133            )
134            .map(|path| path.to_string_lossy().to_string())
135            .map_err(|e| PyIOError::new_err(format!("Failed to write quote ticks: {e}")))
136    }
137
138    /// Write trade tick data to Parquet files.
139    ///
140    /// # Parameters
141    ///
142    /// - `data`: Vector of trade ticks to write
143    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
144    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
145    ///
146    /// # Returns
147    ///
148    /// Returns the path of the created file as a string.
149    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
150    pub fn write_trade_ticks(
151        &self,
152        data: Vec<TradeTick>,
153        start: Option<u64>,
154        end: Option<u64>,
155        skip_disjoint_check: bool,
156    ) -> PyResult<String> {
157        let start_nanos = start.map(UnixNanos::from);
158        let end_nanos = end.map(UnixNanos::from);
159        let data = data.into_boxed_slice();
160
161        self.inner
162            .write_to_parquet(
163                data.as_ref(),
164                start_nanos,
165                end_nanos,
166                Some(skip_disjoint_check),
167            )
168            .map(|path| path.to_string_lossy().to_string())
169            .map_err(|e| PyIOError::new_err(format!("Failed to write trade ticks: {e}")))
170    }
171
172    /// Write order book delta data to Parquet files.
173    ///
174    /// # Parameters
175    ///
176    /// - `data`: Vector of order book deltas to write
177    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
178    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
179    ///
180    /// # Returns
181    ///
182    /// Returns the path of the created file as a string.
183    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
184    pub fn write_order_book_deltas(
185        &self,
186        data: Vec<OrderBookDelta>,
187        start: Option<u64>,
188        end: Option<u64>,
189        skip_disjoint_check: bool,
190    ) -> PyResult<String> {
191        let start_nanos = start.map(UnixNanos::from);
192        let end_nanos = end.map(UnixNanos::from);
193        let data = data.into_boxed_slice();
194
195        self.inner
196            .write_to_parquet(
197                data.as_ref(),
198                start_nanos,
199                end_nanos,
200                Some(skip_disjoint_check),
201            )
202            .map(|path| path.to_string_lossy().to_string())
203            .map_err(|e| PyIOError::new_err(format!("Failed to write order book deltas: {e}")))
204    }
205
206    /// Write bar data to Parquet files.
207    ///
208    /// # Parameters
209    ///
210    /// - `data`: Vector of bars to write
211    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
212    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
213    ///
214    /// # Returns
215    ///
216    /// Returns the path of the created file as a string.
217    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
218    pub fn write_bars(
219        &self,
220        data: Vec<Bar>,
221        start: Option<u64>,
222        end: Option<u64>,
223        skip_disjoint_check: bool,
224    ) -> PyResult<String> {
225        let start_nanos = start.map(UnixNanos::from);
226        let end_nanos = end.map(UnixNanos::from);
227        let data = data.into_boxed_slice();
228
229        self.inner
230            .write_to_parquet(
231                data.as_ref(),
232                start_nanos,
233                end_nanos,
234                Some(skip_disjoint_check),
235            )
236            .map(|path| path.to_string_lossy().to_string())
237            .map_err(|e| PyIOError::new_err(format!("Failed to write bars: {e}")))
238    }
239
240    /// Write order book depth data to Parquet files.
241    ///
242    /// # Parameters
243    ///
244    /// - `data`: Vector of order book depths to write
245    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
246    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
247    ///
248    /// # Returns
249    ///
250    /// Returns the path of the created file as a string.
251    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
252    pub fn write_order_book_depths(
253        &self,
254        data: Vec<OrderBookDepth10>,
255        start: Option<u64>,
256        end: Option<u64>,
257        skip_disjoint_check: bool,
258    ) -> PyResult<String> {
259        let start_nanos = start.map(UnixNanos::from);
260        let end_nanos = end.map(UnixNanos::from);
261        let data = data.into_boxed_slice();
262
263        self.inner
264            .write_to_parquet(
265                data.as_ref(),
266                start_nanos,
267                end_nanos,
268                Some(skip_disjoint_check),
269            )
270            .map(|path| path.to_string_lossy().to_string())
271            .map_err(|e| PyIOError::new_err(format!("Failed to write order book depths: {e}")))
272    }
273
274    /// Write mark price update data to Parquet files.
275    ///
276    /// # Parameters
277    ///
278    /// - `data`: Vector of mark price updates to write
279    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
280    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
281    ///
282    /// # Returns
283    ///
284    /// Returns the path of the created file as a string.
285    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
286    pub fn write_mark_price_updates(
287        &self,
288        data: Vec<MarkPriceUpdate>,
289        start: Option<u64>,
290        end: Option<u64>,
291        skip_disjoint_check: bool,
292    ) -> PyResult<String> {
293        let start_nanos = start.map(UnixNanos::from);
294        let end_nanos = end.map(UnixNanos::from);
295        let data = data.into_boxed_slice();
296
297        self.inner
298            .write_to_parquet(
299                data.as_ref(),
300                start_nanos,
301                end_nanos,
302                Some(skip_disjoint_check),
303            )
304            .map(|path| path.to_string_lossy().to_string())
305            .map_err(|e| PyIOError::new_err(format!("Failed to write mark price updates: {e}")))
306    }
307
308    /// Write index price update data to Parquet files.
309    ///
310    /// # Parameters
311    ///
312    /// - `data`: Vector of index price updates to write
313    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
314    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
315    ///
316    /// # Returns
317    ///
318    /// Returns the path of the created file as a string.
319    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
320    pub fn write_index_price_updates(
321        &self,
322        data: Vec<IndexPriceUpdate>,
323        start: Option<u64>,
324        end: Option<u64>,
325        skip_disjoint_check: bool,
326    ) -> PyResult<String> {
327        let start_nanos = start.map(UnixNanos::from);
328        let end_nanos = end.map(UnixNanos::from);
329        let data = data.into_boxed_slice();
330
331        self.inner
332            .write_to_parquet(
333                data.as_ref(),
334                start_nanos,
335                end_nanos,
336                Some(skip_disjoint_check),
337            )
338            .map(|path| path.to_string_lossy().to_string())
339            .map_err(|e| PyIOError::new_err(format!("Failed to write index price updates: {e}")))
340    }
341
342    /// Write option greeks data to Parquet files.
343    ///
344    /// # Parameters
345    ///
346    /// - `data`: Vector of option greeks to write
347    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
348    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
349    ///
350    /// # Returns
351    ///
352    /// Returns the path of the created file as a string.
353    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
354    pub fn write_option_greeks(
355        &self,
356        data: Vec<OptionGreeks>,
357        start: Option<u64>,
358        end: Option<u64>,
359        skip_disjoint_check: bool,
360    ) -> PyResult<String> {
361        let start_nanos = start.map(UnixNanos::from);
362        let end_nanos = end.map(UnixNanos::from);
363        let data = data.into_boxed_slice();
364
365        self.inner
366            .write_to_parquet(
367                data.as_ref(),
368                start_nanos,
369                end_nanos,
370                Some(skip_disjoint_check),
371            )
372            .map(|path| path.to_string_lossy().to_string())
373            .map_err(|e| PyIOError::new_err(format!("Failed to write option greeks: {e}")))
374    }
375
376    /// Write instruments to Parquet files in the catalog.
377    ///
378    /// Instruments are stored under `data/instruments/{instrument_id}/` using timestamp-ranged
379    /// parquet file names, allowing multiple historical versions of the same instrument to be
380    /// written across separate calls.
381    ///
382    /// # Parameters
383    ///
384    /// - `data`: A Python list of instrument objects (e.g. `CurrencyPair`, Equity).
385    ///
386    /// # Returns
387    ///
388    /// Returns a list of written file paths.
389    #[pyo3(signature = (data))]
390    pub fn write_instruments(&self, data: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
391        let py = data.py();
392        let list = data.cast::<PyList>()?;
393        let mut instruments = Vec::with_capacity(list.len());
394        for item in list.iter() {
395            let py_item: Py<PyAny> = item.unbind();
396            let instrument = pyobject_to_instrument_any(py, py_item)?;
397            instruments.push(instrument);
398        }
399        self.inner
400            .write_instruments(instruments)
401            .map(|paths| {
402                paths
403                    .into_iter()
404                    .map(|p| p.to_string_lossy().to_string())
405                    .collect()
406            })
407            .map_err(|e| PyIOError::new_err(format!("Failed to write instruments: {e}")))
408    }
409
410    /// Query instruments from the catalog.
411    ///
412    /// # Parameters
413    ///
414    /// - `instrument_ids`: Optional list of instrument IDs to filter by. If `None`, returns all instruments.
415    /// - `start`: Optional inclusive lower bound for `ts_init` filtering.
416    /// - `end`: Optional inclusive upper bound for `ts_init` filtering.
417    ///
418    /// # Returns
419    ///
420    /// Returns a list of instrument objects (e.g. `CurrencyPair`, Equity).
421    #[pyo3(signature = (instrument_ids=None, start=None, end=None))]
422    #[expect(clippy::needless_pass_by_value)]
423    pub fn instruments(
424        &self,
425        instrument_ids: Option<Vec<String>>,
426        start: Option<u64>,
427        end: Option<u64>,
428    ) -> PyResult<Vec<Py<PyAny>>> {
429        let rust_instruments = self
430            .inner
431            .query_instruments_filtered(
432                instrument_ids.as_deref(),
433                start.map(UnixNanos::from),
434                end.map(UnixNanos::from),
435            )
436            .map_err(|e| PyIOError::new_err(format!("Failed to query instruments: {e}")))?;
437        Python::attach(|py| {
438            rust_instruments
439                .into_iter()
440                .map(|inst| instrument_any_to_pyobject(py, inst))
441                .collect()
442        })
443    }
444
445    /// Extend file names in the catalog with additional timestamp information.
446    ///
447    /// # Parameters
448    ///
449    /// - `data_cls`: The data class name
450    /// - `instrument_id`: Optional instrument ID filter
451    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
452    /// - `end`: End timestamp (nanoseconds since Unix epoch)
453    #[pyo3(signature = (data_cls, instrument_id=None, *, start, end))]
454    #[expect(clippy::needless_pass_by_value)]
455    pub fn extend_file_name(
456        &self,
457        data_cls: &str,
458        instrument_id: Option<String>,
459        start: u64,
460        end: u64,
461    ) -> PyResult<()> {
462        let start_nanos = UnixNanos::from(start);
463        let end_nanos = UnixNanos::from(end);
464
465        self.inner
466            .extend_file_name(data_cls, instrument_id.as_deref(), start_nanos, end_nanos)
467            .map_err(|e| PyIOError::new_err(format!("Failed to extend file name: {e}")))
468    }
469
470    /// Consolidate all data files in the catalog within the specified time range.
471    ///
472    /// # Parameters
473    ///
474    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
475    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
476    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
477    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
478    #[pyo3(signature = (start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
479    pub fn consolidate_catalog(
480        &self,
481        start: Option<u64>,
482        end: Option<u64>,
483        ensure_contiguous_files: Option<bool>,
484        deduplicate: Option<bool>,
485    ) -> PyResult<()> {
486        let start_nanos = start.map(UnixNanos::from);
487        let end_nanos = end.map(UnixNanos::from);
488
489        self.inner
490            .consolidate_catalog(start_nanos, end_nanos, ensure_contiguous_files, deduplicate)
491            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate catalog: {e}")))
492    }
493
494    /// Consolidate data files for a specific data type within the specified time range.
495    ///
496    /// # Parameters
497    ///
498    /// - `type_name`: The data type name to consolidate
499    /// - `instrument_id`: Optional instrument ID filter
500    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
501    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
502    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
503    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
504    #[pyo3(signature = (type_name, instrument_id=None, start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
505    #[expect(clippy::needless_pass_by_value)]
506    pub fn consolidate_data(
507        &self,
508        type_name: &str,
509        instrument_id: Option<String>,
510        start: Option<u64>,
511        end: Option<u64>,
512        ensure_contiguous_files: Option<bool>,
513        deduplicate: Option<bool>,
514    ) -> PyResult<()> {
515        let start_nanos = start.map(UnixNanos::from);
516        let end_nanos = end.map(UnixNanos::from);
517
518        self.inner
519            .consolidate_data(
520                type_name,
521                instrument_id.as_deref(),
522                start_nanos,
523                end_nanos,
524                ensure_contiguous_files,
525                deduplicate,
526            )
527            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate data: {e}")))
528    }
529
530    /// Consolidate all data files in the catalog by splitting them into fixed time periods.
531    ///
532    /// This method identifies all leaf directories in the catalog that contain parquet files
533    /// and consolidates them by period. A leaf directory is one that contains files but no subdirectories.
534    /// This is a convenience method that effectively calls `consolidate_data_by_period` for all data types
535    /// and instrument IDs in the catalog.
536    ///
537    /// # Parameters
538    ///
539    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
540    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
541    /// - `start`: Optional start timestamp for the consolidation range (nanoseconds since Unix epoch)
542    /// - `end`: Optional end timestamp for the consolidation range (nanoseconds since Unix epoch)
543    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
544    #[pyo3(signature = (period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
545    pub fn consolidate_catalog_by_period(
546        &mut self,
547        period_nanos: Option<u64>,
548        start: Option<u64>,
549        end: Option<u64>,
550        ensure_contiguous_files: Option<bool>,
551    ) -> PyResult<()> {
552        let start_nanos = start.map(UnixNanos::from);
553        let end_nanos = end.map(UnixNanos::from);
554
555        self.inner
556            .consolidate_catalog_by_period(
557                period_nanos,
558                start_nanos,
559                end_nanos,
560                ensure_contiguous_files,
561            )
562            .map_err(|e| {
563                PyIOError::new_err(format!("Failed to consolidate catalog by period: {e}"))
564            })
565    }
566
567    /// Consolidate data files by splitting them into fixed time periods.
568    ///
569    /// This method queries data by period and writes consolidated files immediately,
570    /// using efficient period-based consolidation logic. When start/end boundaries intersect existing files,
571    /// the function automatically splits those files to preserve all data.
572    ///
573    /// # Parameters
574    ///
575    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars")
576    /// - `identifier`: Optional instrument ID to consolidate. If None, consolidates all instruments
577    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
578    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
579    /// - `start`: Optional start timestamp for consolidation range (nanoseconds since Unix epoch)
580    /// - `end`: Optional end timestamp for consolidation range (nanoseconds since Unix epoch)
581    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
582    #[pyo3(signature = (type_name, identifier=None, period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
583    #[expect(clippy::needless_pass_by_value)]
584    pub fn consolidate_data_by_period(
585        &mut self,
586        type_name: &str,
587        identifier: Option<String>,
588        period_nanos: Option<u64>,
589        start: Option<u64>,
590        end: Option<u64>,
591        ensure_contiguous_files: Option<bool>,
592    ) -> PyResult<()> {
593        let start_nanos = start.map(UnixNanos::from);
594        let end_nanos = end.map(UnixNanos::from);
595
596        self.inner
597            .consolidate_data_by_period(
598                type_name,
599                identifier.as_deref(),
600                period_nanos,
601                start_nanos,
602                end_nanos,
603                ensure_contiguous_files,
604            )
605            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate data by period: {e}")))
606    }
607
608    /// Reset all catalog file names to their canonical form.
609    pub fn reset_all_file_names(&self) -> PyResult<()> {
610        self.inner
611            .reset_all_file_names()
612            .map_err(|e| PyIOError::new_err(format!("Failed to reset catalog file names: {e}")))
613    }
614
615    /// Reset data file names for a specific data class to their canonical form.
616    ///
617    /// # Parameters
618    ///
619    /// - `data_cls`: The data class name
620    /// - `instrument_id`: Optional instrument ID filter
621    #[pyo3(signature = (data_cls, instrument_id=None))]
622    #[expect(clippy::needless_pass_by_value)]
623    pub fn reset_data_file_names(
624        &self,
625        data_cls: &str,
626        instrument_id: Option<String>,
627    ) -> PyResult<()> {
628        self.inner
629            .reset_data_file_names(data_cls, instrument_id.as_deref())
630            .map_err(|e| PyIOError::new_err(format!("Failed to reset data file names: {e}")))
631    }
632
633    /// Delete data within a specified time range across the entire catalog.
634    ///
635    /// This method identifies all leaf directories in the catalog that contain parquet files
636    /// and deletes data within the specified time range from each directory. A leaf directory
637    /// is one that contains files but no subdirectories. This is a convenience method that
638    /// effectively calls `delete_data_range` for all data types and instrument IDs in the catalog.
639    ///
640    /// # Parameters
641    ///
642    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
643    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
644    ///
645    /// # Notes
646    ///
647    /// - This operation permanently removes data and cannot be undone
648    /// - The deletion process handles file intersections intelligently by splitting files
649    ///   when they partially overlap with the deletion range
650    /// - Files completely within the deletion range are removed entirely
651    /// - Files partially overlapping the deletion range are split to preserve data outside the range
652    /// - This method is useful for bulk data cleanup operations across the entire catalog
653    /// - Empty directories are not automatically removed after deletion
654    #[pyo3(signature = (start=None, end=None))]
655    pub fn delete_catalog_range(&mut self, start: Option<u64>, end: Option<u64>) -> PyResult<()> {
656        let start_nanos = start.map(UnixNanos::from);
657        let end_nanos = end.map(UnixNanos::from);
658
659        self.inner
660            .delete_catalog_range(start_nanos, end_nanos)
661            .map_err(|e| PyIOError::new_err(format!("Failed to delete catalog range: {e}")))
662    }
663
664    /// Delete data within a specified time range for a specific data type and instrument.
665    ///
666    /// This method identifies all parquet files that intersect with the specified time range
667    /// and handles them appropriately:
668    /// - Files completely within the range are deleted
669    /// - Files partially overlapping the range are split to preserve data outside the range
670    /// - The original intersecting files are removed after processing
671    ///
672    /// # Parameters
673    ///
674    /// - `type_name`: The data type directory name (e.g., "quotes", "trades", "bars")
675    /// - `instrument_id`: Optional instrument ID to delete data for. If None, deletes data across all instruments
676    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
677    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
678    ///
679    /// # Notes
680    ///
681    /// - This operation permanently removes data and cannot be undone
682    /// - Files that partially overlap the deletion range are split to preserve data outside the range
683    /// - The method ensures data integrity by using atomic operations where possible
684    /// - Empty directories are not automatically removed after deletion
685    #[pyo3(signature = (type_name, instrument_id=None, start=None, end=None))]
686    #[expect(clippy::needless_pass_by_value)]
687    pub fn delete_data_range(
688        &mut self,
689        type_name: &str,
690        instrument_id: Option<String>,
691        start: Option<u64>,
692        end: Option<u64>,
693    ) -> PyResult<()> {
694        let start_nanos = start.map(UnixNanos::from);
695        let end_nanos = end.map(UnixNanos::from);
696
697        self.inner
698            .delete_data_range(type_name, instrument_id.as_deref(), start_nanos, end_nanos)
699            .map_err(|e| PyIOError::new_err(format!("Failed to delete data range: {e}")))
700    }
701
702    /// Write custom data to Parquet files.
703    ///
704    /// Requires `CustomData` wrappers. Callers must wrap raw custom objects in
705    /// `CustomData(data_type=DataType(cls, metadata=...), data=...)` before writing.
706    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
707    pub fn write_custom_data(
708        &self,
709        _py: Python<'_>,
710        data: Vec<Bound<'_, PyAny>>,
711        start: Option<u64>,
712        end: Option<u64>,
713        skip_disjoint_check: bool,
714    ) -> PyResult<String> {
715        use nautilus_model::data::CustomData;
716
717        let mut custom_items: Vec<CustomData> = Vec::with_capacity(data.len());
718        for obj in data {
719            let custom = obj.extract::<CustomData>().map_err(|_| {
720                to_pytype_err(
721                    "write_custom_data requires CustomData wrappers; wrap with CustomData(data_type=DataType(cls, metadata=...), data=...)",
722                )
723            })?;
724            custom_items.push(custom);
725        }
726
727        let start_nanos = start.map(UnixNanos::from);
728        let end_nanos = end.map(UnixNanos::from);
729
730        self.inner
731            .write_custom_data_batch(
732                custom_items,
733                start_nanos,
734                end_nanos,
735                Some(skip_disjoint_check),
736            )
737            .map(|path| path.to_string_lossy().to_string())
738            .map_err(|e| PyIOError::new_err(format!("Failed to write custom data: {e}")))
739    }
740
741    /// List all instrument IDs available in the catalog for a given data type.
742    pub fn list_instruments(&self, data_type: &str) -> PyResult<Vec<String>> {
743        self.inner
744            .list_instruments(data_type)
745            .map_err(|e| PyIOError::new_err(format!("Failed to list instruments: {e}")))
746    }
747
748    /// List all Parquet files in the catalog for a given data type and instrument.
749    pub fn list_parquet_files(
750        &self,
751        data_type: &str,
752        instrument_id: &str,
753    ) -> PyResult<Vec<String>> {
754        let directory = format!("data/{data_type}/{instrument_id}");
755        self.inner
756            .list_parquet_files(&directory)
757            .map_err(|e| PyIOError::new_err(format!("Failed to list parquet files: {e}")))
758    }
759
760    /// Query files in the catalog matching the specified criteria.
761    ///
762    /// # Parameters
763    ///
764    /// - `data_cls`: The data class name to query
765    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
766    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
767    ///   For bars, partial matching is supported.
768    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
769    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
770    ///
771    /// # Returns
772    ///
773    /// Returns a list of file paths matching the criteria.
774    #[pyo3(signature = (data_cls, identifiers=None, start=None, end=None))]
775    pub fn query_files(
776        &self,
777        data_cls: &str,
778        identifiers: Option<Vec<String>>,
779        start: Option<u64>,
780        end: Option<u64>,
781    ) -> PyResult<Vec<String>> {
782        let start_nanos = start.map(UnixNanos::from);
783        let end_nanos = end.map(UnixNanos::from);
784
785        self.inner
786            .query_files(data_cls, identifiers, start_nanos, end_nanos)
787            .map_err(|e| PyIOError::new_err(format!("Failed to query files list: {e}")))
788    }
789
790    /// Get missing time intervals for a data request.
791    ///
792    /// # Parameters
793    ///
794    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
795    /// - `end`: End timestamp (nanoseconds since Unix epoch)
796    /// - `data_cls`: The data class name
797    /// - `instrument_id`: Optional instrument ID filter
798    ///
799    /// # Returns
800    ///
801    /// Returns a list of (start, end) timestamp tuples representing missing intervals.
802    #[pyo3(signature = (start, end, data_cls, instrument_id=None))]
803    #[expect(clippy::needless_pass_by_value)]
804    pub fn get_missing_intervals_for_request(
805        &self,
806        start: u64,
807        end: u64,
808        data_cls: &str,
809        instrument_id: Option<String>,
810    ) -> PyResult<Vec<(u64, u64)>> {
811        self.inner
812            .get_missing_intervals_for_request(start, end, data_cls, instrument_id.as_deref())
813            .map_err(|e| PyIOError::new_err(format!("Failed to get missing intervals: {e}")))
814    }
815
816    /// Query the first timestamp for a specific data class and instrument.
817    ///
818    /// # Parameters
819    ///
820    /// - `data_cls`: The data class name
821    /// - `instrument_id`: Optional instrument ID filter
822    ///
823    /// # Returns
824    ///
825    /// Returns the first timestamp as nanoseconds since Unix epoch, or None if no data exists.
826    #[pyo3(signature = (data_cls, instrument_id=None))]
827    #[expect(clippy::needless_pass_by_value)]
828    pub fn query_first_timestamp(
829        &self,
830        data_cls: &str,
831        instrument_id: Option<String>,
832    ) -> PyResult<Option<u64>> {
833        self.inner
834            .query_first_timestamp(data_cls, instrument_id.as_deref())
835            .map_err(|e| PyIOError::new_err(format!("Failed to query first timestamp: {e}")))
836    }
837
838    /// Query the last timestamp for a specific data class and instrument.
839    ///
840    /// # Parameters
841    ///
842    /// - `data_cls`: The data class name
843    /// - `instrument_id`: Optional instrument ID filter
844    ///
845    /// # Returns
846    ///
847    /// Returns the last timestamp as nanoseconds since Unix epoch, or None if no data exists.
848    #[pyo3(signature = (data_cls, instrument_id=None))]
849    #[expect(clippy::needless_pass_by_value)]
850    pub fn query_last_timestamp(
851        &self,
852        data_cls: &str,
853        instrument_id: Option<String>,
854    ) -> PyResult<Option<u64>> {
855        self.inner
856            .query_last_timestamp(data_cls, instrument_id.as_deref())
857            .map_err(|e| PyIOError::new_err(format!("Failed to query last timestamp: {e}")))
858    }
859
860    /// Get time intervals covered by data for a specific data class and instrument.
861    ///
862    /// # Parameters
863    ///
864    /// - `data_cls`: The data class name
865    /// - `instrument_id`: Optional instrument ID filter
866    ///
867    /// # Returns
868    ///
869    /// Returns a list of (start, end) timestamp tuples representing covered intervals.
870    #[pyo3(signature = (data_cls, instrument_id=None))]
871    #[expect(clippy::needless_pass_by_value)]
872    pub fn get_intervals(
873        &self,
874        data_cls: &str,
875        instrument_id: Option<String>,
876    ) -> PyResult<Vec<(u64, u64)>> {
877        self.inner
878            .get_intervals(data_cls, instrument_id.as_deref())
879            .map_err(|e| PyIOError::new_err(format!("Failed to get intervals: {e}")))
880    }
881
882    /// Query Parquet files for data matching the given criteria.
883    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None, files=None, optimize_file_loading=true))]
884    #[expect(
885        clippy::too_many_arguments,
886        clippy::too_many_lines,
887        reason = "PyO3 query binding mirrors the Python catalog API"
888    )]
889    pub fn query(
890        &mut self,
891        py: Python<'_>,
892        data_type: &str,
893        identifiers: Option<Vec<String>>,
894        start: Option<u64>,
895        end: Option<u64>,
896        where_clause: Option<&str>,
897        files: Option<Vec<String>>,
898        optimize_file_loading: bool,
899    ) -> PyResult<Vec<Py<PyAny>>> {
900        let start_nanos = start.map(UnixNanos::from);
901        let end_nanos = end.map(UnixNanos::from);
902
903        let data = match data_type {
904            "quotes" => {
905                let ticks = self
906                    .inner
907                    .query_typed_data::<QuoteTick>(
908                        identifiers,
909                        start_nanos,
910                        end_nanos,
911                        where_clause,
912                        files,
913                        optimize_file_loading,
914                    )
915                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
916                ticks.into_iter().map(Data::from).collect()
917            }
918            "trades" => {
919                let ticks = self
920                    .inner
921                    .query_typed_data::<TradeTick>(
922                        identifiers,
923                        start_nanos,
924                        end_nanos,
925                        where_clause,
926                        files,
927                        optimize_file_loading,
928                    )
929                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
930                ticks.into_iter().map(Data::from).collect()
931            }
932            "bars" => {
933                let bars = self
934                    .inner
935                    .query_typed_data::<Bar>(
936                        identifiers,
937                        start_nanos,
938                        end_nanos,
939                        where_clause,
940                        files,
941                        optimize_file_loading,
942                    )
943                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
944                bars.into_iter().map(Data::from).collect()
945            }
946            "order_book_deltas" => {
947                let deltas = self
948                    .inner
949                    .query_typed_data::<OrderBookDelta>(
950                        identifiers,
951                        start_nanos,
952                        end_nanos,
953                        where_clause,
954                        files,
955                        optimize_file_loading,
956                    )
957                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
958                deltas.into_iter().map(Data::from).collect()
959            }
960            "order_book_depths" => {
961                let depths = self
962                    .inner
963                    .query_typed_data::<OrderBookDepth10>(
964                        identifiers,
965                        start_nanos,
966                        end_nanos,
967                        where_clause,
968                        files,
969                        optimize_file_loading,
970                    )
971                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
972                depths.into_iter().map(Data::from).collect()
973            }
974            "index_prices" => {
975                let prices = self
976                    .inner
977                    .query_typed_data::<IndexPriceUpdate>(
978                        identifiers,
979                        start_nanos,
980                        end_nanos,
981                        where_clause,
982                        files,
983                        optimize_file_loading,
984                    )
985                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
986                prices.into_iter().map(Data::from).collect()
987            }
988            "mark_prices" => {
989                let prices = self
990                    .inner
991                    .query_typed_data::<MarkPriceUpdate>(
992                        identifiers,
993                        start_nanos,
994                        end_nanos,
995                        where_clause,
996                        files,
997                        optimize_file_loading,
998                    )
999                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1000                prices.into_iter().map(Data::from).collect()
1001            }
1002            "option_greeks" => {
1003                let greeks = self
1004                    .inner
1005                    .query_typed_data::<OptionGreeks>(
1006                        identifiers,
1007                        start_nanos,
1008                        end_nanos,
1009                        where_clause,
1010                        files,
1011                        optimize_file_loading,
1012                    )
1013                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1014                greeks.into_iter().map(Data::from).collect()
1015            }
1016            "instrument_status" => {
1017                let statuses = self
1018                    .inner
1019                    .query_typed_data::<InstrumentStatus>(
1020                        identifiers,
1021                        start_nanos,
1022                        end_nanos,
1023                        where_clause,
1024                        files,
1025                        optimize_file_loading,
1026                    )
1027                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1028                statuses.into_iter().map(Data::from).collect()
1029            }
1030            "instrument_closes" => {
1031                let closes = self
1032                    .inner
1033                    .query_typed_data::<InstrumentClose>(
1034                        identifiers,
1035                        start_nanos,
1036                        end_nanos,
1037                        where_clause,
1038                        files,
1039                        optimize_file_loading,
1040                    )
1041                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1042                closes.into_iter().map(Data::from).collect()
1043            }
1044            _ => py
1045                .detach(|| {
1046                    self.inner.query_custom_data_dynamic(
1047                        data_type,
1048                        identifiers.as_deref(),
1049                        start_nanos,
1050                        end_nanos,
1051                        where_clause,
1052                        files.clone(),
1053                        optimize_file_loading,
1054                    )
1055                })
1056                .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?,
1057        };
1058
1059        let mut python_objects = Vec::new();
1060        for item in data {
1061            python_objects.push(data_to_pyobject(py, item)?);
1062        }
1063        Ok(python_objects)
1064    }
1065
1066    /// Query quote tick data from Parquet files.
1067    ///
1068    /// # Parameters
1069    ///
1070    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1071    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1072    ///   For bars, partial matching is supported.
1073    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1074    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1075    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1076    ///
1077    /// # Returns
1078    ///
1079    /// Returns a vector of `QuoteTick` objects matching the query criteria.
1080    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1081    pub fn query_quote_ticks(
1082        &mut self,
1083        identifiers: Option<Vec<String>>,
1084        start: Option<u64>,
1085        end: Option<u64>,
1086        where_clause: Option<&str>,
1087    ) -> PyResult<Vec<QuoteTick>> {
1088        let start_nanos = start.map(UnixNanos::from);
1089        let end_nanos = end.map(UnixNanos::from);
1090
1091        self.inner
1092            .query_typed_data::<QuoteTick>(
1093                identifiers,
1094                start_nanos,
1095                end_nanos,
1096                where_clause,
1097                None,
1098                true, // optimize_file_loading=true for directory-based registration (default)
1099            )
1100            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1101    }
1102
1103    /// Query trade tick data from Parquet files.
1104    ///
1105    /// # Parameters
1106    ///
1107    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1108    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1109    ///   For bars, partial matching is supported.
1110    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1111    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1112    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1113    ///
1114    /// # Returns
1115    ///
1116    /// Returns a vector of `TradeTick` objects matching the query criteria.
1117    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1118    pub fn query_trade_ticks(
1119        &mut self,
1120        identifiers: Option<Vec<String>>,
1121        start: Option<u64>,
1122        end: Option<u64>,
1123        where_clause: Option<&str>,
1124    ) -> PyResult<Vec<TradeTick>> {
1125        let start_nanos = start.map(UnixNanos::from);
1126        let end_nanos = end.map(UnixNanos::from);
1127
1128        self.inner
1129            .query_typed_data::<TradeTick>(
1130                identifiers,
1131                start_nanos,
1132                end_nanos,
1133                where_clause,
1134                None,
1135                true, // optimize_file_loading=true for directory-based registration (default)
1136            )
1137            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1138    }
1139
1140    /// Query order book delta data from Parquet files.
1141    ///
1142    /// # Parameters
1143    ///
1144    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1145    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1146    ///   For bars, partial matching is supported.
1147    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1148    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1149    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1150    ///
1151    /// # Returns
1152    ///
1153    /// Returns a vector of `OrderBookDelta` objects matching the query criteria.
1154    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1155    pub fn query_order_book_deltas(
1156        &mut self,
1157        identifiers: Option<Vec<String>>,
1158        start: Option<u64>,
1159        end: Option<u64>,
1160        where_clause: Option<&str>,
1161    ) -> PyResult<Vec<OrderBookDelta>> {
1162        let start_nanos = start.map(UnixNanos::from);
1163        let end_nanos = end.map(UnixNanos::from);
1164
1165        self.inner
1166            .query_typed_data::<OrderBookDelta>(
1167                identifiers,
1168                start_nanos,
1169                end_nanos,
1170                where_clause,
1171                None,
1172                true, // optimize_file_loading=true for directory-based registration (default)
1173            )
1174            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1175    }
1176
1177    /// Query bar data from Parquet files.
1178    ///
1179    /// # Parameters
1180    ///
1181    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1182    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1183    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match all bar types for that instrument).
1184    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1185    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1186    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1187    ///
1188    /// # Returns
1189    ///
1190    /// Returns a vector of Bar objects matching the query criteria.
1191    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1192    pub fn query_bars(
1193        &mut self,
1194        identifiers: Option<Vec<String>>,
1195        start: Option<u64>,
1196        end: Option<u64>,
1197        where_clause: Option<&str>,
1198    ) -> PyResult<Vec<Bar>> {
1199        let start_nanos = start.map(UnixNanos::from);
1200        let end_nanos = end.map(UnixNanos::from);
1201
1202        self.inner
1203            .query_typed_data::<Bar>(
1204                identifiers,
1205                start_nanos,
1206                end_nanos,
1207                where_clause,
1208                None,
1209                true, // optimize_file_loading=true for directory-based registration (default)
1210            )
1211            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1212    }
1213
1214    /// Query order book depth data from Parquet files.
1215    ///
1216    /// # Parameters
1217    ///
1218    /// - `instrument_ids`: Optional list of instrument IDs to filter by
1219    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1220    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1221    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1222    ///
1223    /// # Returns
1224    ///
1225    /// Returns a vector of `OrderBookDepth10` objects matching the query criteria.
1226    #[pyo3(signature = (instrument_ids=None, start=None, end=None, where_clause=None))]
1227    pub fn query_order_book_depths(
1228        &mut self,
1229        instrument_ids: Option<Vec<String>>,
1230        start: Option<u64>,
1231        end: Option<u64>,
1232        where_clause: Option<&str>,
1233    ) -> PyResult<Vec<OrderBookDepth10>> {
1234        let start_nanos = start.map(UnixNanos::from);
1235        let end_nanos = end.map(UnixNanos::from);
1236
1237        self.inner
1238            .query_typed_data::<OrderBookDepth10>(
1239                instrument_ids,
1240                start_nanos,
1241                end_nanos,
1242                where_clause,
1243                None,
1244                true, // optimize_file_loading=true for directory-based registration (default)
1245            )
1246            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1247    }
1248
1249    /// Query mark price update data from Parquet files.
1250    ///
1251    /// # Parameters
1252    ///
1253    /// - `instrument_ids`: Optional list of instrument IDs to filter by
1254    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1255    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1256    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1257    ///
1258    /// # Returns
1259    ///
1260    /// Returns a vector of `MarkPriceUpdate` objects matching the query criteria.
1261    #[pyo3(signature = (instrument_ids=None, start=None, end=None, where_clause=None))]
1262    pub fn query_mark_price_updates(
1263        &mut self,
1264        instrument_ids: Option<Vec<String>>,
1265        start: Option<u64>,
1266        end: Option<u64>,
1267        where_clause: Option<&str>,
1268    ) -> PyResult<Vec<MarkPriceUpdate>> {
1269        let start_nanos = start.map(UnixNanos::from);
1270        let end_nanos = end.map(UnixNanos::from);
1271
1272        self.inner
1273            .query_typed_data::<MarkPriceUpdate>(
1274                instrument_ids,
1275                start_nanos,
1276                end_nanos,
1277                where_clause,
1278                None,
1279                true, // optimize_file_loading=true for directory-based registration (default)
1280            )
1281            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1282    }
1283
1284    /// Query index price update data from Parquet files.
1285    ///
1286    /// # Parameters
1287    ///
1288    /// - `instrument_ids`: Optional list of instrument IDs to filter by
1289    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1290    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1291    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1292    ///
1293    /// # Returns
1294    ///
1295    /// Returns a vector of `IndexPriceUpdate` objects matching the query criteria.
1296    #[pyo3(signature = (instrument_ids=None, start=None, end=None, where_clause=None))]
1297    pub fn query_index_price_updates(
1298        &mut self,
1299        instrument_ids: Option<Vec<String>>,
1300        start: Option<u64>,
1301        end: Option<u64>,
1302        where_clause: Option<&str>,
1303    ) -> PyResult<Vec<IndexPriceUpdate>> {
1304        let start_nanos = start.map(UnixNanos::from);
1305        let end_nanos = end.map(UnixNanos::from);
1306
1307        self.inner
1308            .query_typed_data::<IndexPriceUpdate>(
1309                instrument_ids,
1310                start_nanos,
1311                end_nanos,
1312                where_clause,
1313                None,
1314                true, // optimize_file_loading=true for directory-based registration (default)
1315            )
1316            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1317    }
1318
1319    /// Query option greeks data from Parquet files.
1320    #[pyo3(signature = (instrument_ids=None, start=None, end=None, where_clause=None))]
1321    pub fn query_option_greeks(
1322        &mut self,
1323        instrument_ids: Option<Vec<String>>,
1324        start: Option<u64>,
1325        end: Option<u64>,
1326        where_clause: Option<&str>,
1327    ) -> PyResult<Vec<OptionGreeks>> {
1328        let start_nanos = start.map(UnixNanos::from);
1329        let end_nanos = end.map(UnixNanos::from);
1330
1331        self.inner
1332            .query_typed_data::<OptionGreeks>(
1333                instrument_ids,
1334                start_nanos,
1335                end_nanos,
1336                where_clause,
1337                None,
1338                true,
1339            )
1340            .map_err(|e| PyIOError::new_err(format!("Failed to query data: {e}")))
1341    }
1342
1343    /// List all data types available in the catalog.
1344    ///
1345    /// # Returns
1346    ///
1347    /// Returns a list of data type names (as directory stems) in the catalog.
1348    pub fn list_data_types(&self) -> PyResult<Vec<String>> {
1349        self.inner
1350            .list_data_types()
1351            .map_err(|e| PyIOError::new_err(format!("Failed to list data types: {e}")))
1352    }
1353
1354    /// List all live run IDs available in the catalog.
1355    ///
1356    /// # Returns
1357    ///
1358    /// Returns a list of live run IDs (as directory stems) in the catalog.
1359    pub fn list_live_runs(&self) -> PyResult<Vec<String>> {
1360        self.inner
1361            .list_live_runs()
1362            .map_err(|e| PyIOError::new_err(format!("Failed to list live runs: {e}")))
1363    }
1364
1365    /// List all backtest run IDs available in the catalog.
1366    ///
1367    /// # Returns
1368    ///
1369    /// Returns a list of backtest run IDs (as directory stems) in the catalog.
1370    pub fn list_backtest_runs(&self) -> PyResult<Vec<String>> {
1371        self.inner
1372            .list_backtest_runs()
1373            .map_err(|e| PyIOError::new_err(format!("Failed to list backtest runs: {e}")))
1374    }
1375
1376    /// List all backtest run instances available in the catalog.
1377    pub fn list_backtests(&self) -> PyResult<Vec<String>> {
1378        self.inner
1379            .list_backtest_runs()
1380            .map_err(|e| PyIOError::new_err(format!("Failed to list backtests: {e}")))
1381    }
1382
1383    /// Read data from a live run instance.
1384    ///
1385    /// # Parameters
1386    ///
1387    /// - `instance_id`: The ID of the live run instance
1388    ///
1389    /// # Returns
1390    ///
1391    /// Returns a list of data objects from the live run, sorted by timestamp.
1392    #[pyo3(signature = (instance_id))]
1393    pub fn read_live_run(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1394        let data = self
1395            .inner
1396            .read_live_run(instance_id)
1397            .map_err(|e| PyIOError::new_err(format!("Failed to read live run: {e}")))?;
1398
1399        let mut python_objects = Vec::new();
1400        for item in data {
1401            python_objects.push(data_to_pyobject(py, item)?);
1402        }
1403        Ok(python_objects)
1404    }
1405
1406    /// Read data from a backtest run instance.
1407    ///
1408    /// # Parameters
1409    ///
1410    /// - `instance_id`: The ID of the backtest run instance
1411    ///
1412    /// # Returns
1413    ///
1414    /// Returns a list of data objects from the backtest run, sorted by timestamp.
1415    #[pyo3(signature = (instance_id))]
1416    pub fn read_backtest(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1417        let data = self
1418            .inner
1419            .read_backtest(instance_id)
1420            .map_err(|e| PyIOError::new_err(format!("Failed to read backtest: {e}")))?;
1421
1422        let mut python_objects = Vec::new();
1423        for item in data {
1424            python_objects.push(data_to_pyobject(py, item)?);
1425        }
1426        Ok(python_objects)
1427    }
1428
1429    /// Convert stream data from feather files to parquet files.
1430    ///
1431    /// This method reads data from feather files generated during a backtest or live run
1432    /// and writes it to the catalog in parquet format. It's useful for converting temporary
1433    /// stream data into a more permanent and queryable format.
1434    ///
1435    /// # Parameters
1436    ///
1437    /// - `instance_id`: The ID of the backtest or live run instance
1438    /// - `data_cls`: The data class name (e.g., "quotes", "trades", "bars")
1439    /// - `subdirectory`: Optional subdirectory containing the feather files. Either "backtest" or "live" (default: "backtest")
1440    /// - `identifiers`: Optional list of identifiers to filter by (instrument IDs or bar types)
1441    /// - `use_ts_event_for_ts_init`: If true, replaces the `ts_init` column with `ts_event` column values before deserializing
1442    ///
1443    /// # Returns
1444    ///
1445    /// Returns nothing on success.
1446    ///
1447    /// # Examples
1448    ///
1449    /// ```python
1450    /// # Convert backtest stream data to parquet
1451    /// catalog.convert_stream_to_data(
1452    ///     "instance-123",
1453    ///     "quotes",
1454    ///     subdirectory="backtest"
1455    /// )
1456    ///
1457    /// # Convert live run data with identifier filtering
1458    /// catalog.convert_stream_to_data(
1459    ///     "instance-456",
1460    ///     "trades",
1461    ///     subdirectory="live",
1462    ///     identifiers=["EUR/USD.SIM"]
1463    /// )
1464    /// ```
1465    #[pyo3(signature = (instance_id, data_cls, subdirectory=None, identifiers=None, use_ts_event_for_ts_init=false))]
1466    #[expect(clippy::needless_pass_by_value)]
1467    pub fn convert_stream_to_data(
1468        &mut self,
1469        instance_id: &str,
1470        data_cls: &str,
1471        subdirectory: Option<&str>,
1472        identifiers: Option<Vec<String>>,
1473        use_ts_event_for_ts_init: bool,
1474    ) -> PyResult<()> {
1475        let subdir = subdirectory.unwrap_or("backtest");
1476
1477        match self.inner.convert_stream_to_data(
1478            instance_id,
1479            data_cls,
1480            Some(subdir),
1481            identifiers.as_deref(),
1482            use_ts_event_for_ts_init,
1483        ) {
1484            Ok(()) => Ok(()),
1485            Err(e) => Err(PyIOError::new_err(format!(
1486                "Failed to convert stream to data: {e}"
1487            ))),
1488        }
1489    }
1490
1491    /// Query custom data from Parquet files.
1492    #[pyo3(signature = (type_name, identifiers=None, start=None, end=None, where_clause=None))]
1493    #[expect(clippy::needless_pass_by_value)]
1494    pub fn query_custom_data(
1495        &mut self,
1496        py: Python<'_>,
1497        type_name: &str,
1498        identifiers: Option<Vec<String>>,
1499        start: Option<u64>,
1500        end: Option<u64>,
1501        where_clause: Option<&str>,
1502    ) -> PyResult<Vec<Py<PyAny>>> {
1503        let start_nanos = start.map(UnixNanos::from);
1504        let end_nanos = end.map(UnixNanos::from);
1505
1506        let data = py
1507            .detach(|| {
1508                self.inner.query_custom_data_dynamic(
1509                    type_name,
1510                    identifiers.as_deref(),
1511                    start_nanos,
1512                    end_nanos,
1513                    where_clause,
1514                    None,
1515                    true,
1516                )
1517            })
1518            .map_err(|e| PyIOError::new_err(format!("Failed to query custom data: {e}")))?;
1519
1520        let mut python_objects = Vec::new();
1521
1522        for item in data {
1523            let py_obj: Py<PyAny> = match item {
1524                Data::Custom(custom) => Py::new(py, custom.clone())?.into_any(),
1525                _ => return Err(PyIOError::new_err("Expected custom data")),
1526            };
1527            python_objects.push(py_obj);
1528        }
1529        Ok(python_objects)
1530    }
1531}