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