Skip to main content

nautilus_persistence/python/backend/parquet/
catalog.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16#![expect(
17    clippy::too_many_arguments,
18    reason = "PyO3 catalog wrapper mirrors Python API dispatch surface"
19)]
20
21use std::{collections::HashMap, sync::Mutex};
22
23use nautilus_common::{
24    config::ConfigError, enums::Environment, python::config_error_to_pyvalue_err,
25};
26use nautilus_core::{
27    UnixNanos,
28    python::{to_pytype_err, to_pyvalue_err},
29};
30use nautilus_model::{
31    data::{
32        Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus,
33        MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
34        TradeTick, close::InstrumentClose,
35    },
36    python::{
37        data::data_to_pyobject,
38        instruments::{
39            PyNautilusInstrumentType, instrument_any_to_pyobject, pyobject_to_instrument_any,
40        },
41    },
42};
43use nautilus_serialization::{
44    arrow::{
45        DecodeTypedFromRecordBatch, EncodeToRecordBatch, display::instrument::encode_instruments,
46    },
47    python::arrow::{arrow_record_batches_to_pyarrow_stream, arrow_record_batches_to_pybytes},
48};
49use pyo3::{
50    exceptions::PyIOError,
51    prelude::*,
52    types::{PyBytes, PyDict, PyList},
53};
54
55use crate::{
56    backend::{migration::build_catalog_migration_plan, parquet::catalog::ParquetDataCatalog},
57    catalog::{
58        traits::{CatalogQuery, CatalogReader, CatalogRecordQuery, CatalogWriter},
59        types::{CatalogDataType, HasCatalogDataType, parquet_catalog_data_type_path_prefixes},
60    },
61    config::CatalogCompression,
62    python::backend::{
63        PyCatalogDataType, arrow_ipc_batches, arrow_ipc_data_schema, arrow_ipc_record_schema,
64        arrow_record_batches_from_pybytes, catalog_metadata_to_pydict, catalog_record_type_from_py,
65        nautilus_data_type_from_py, to_pyio_err, write_record_params_from_py,
66    },
67};
68
69fn write_parquet_data<T>(
70    py: Python<'_>,
71    catalog: &PyParquetDataCatalog,
72    data: Vec<T>,
73    start: Option<u64>,
74    end: Option<u64>,
75    skip_disjoint_check: bool,
76    label: &str,
77) -> PyResult<String>
78where
79    T: HasTsInit + EncodeToRecordBatch + HasCatalogDataType + Send,
80{
81    catalog
82        .mutate(py, move || {
83            catalog.inner.write_to_parquet(
84                &data,
85                start.map(UnixNanos::from),
86                end.map(UnixNanos::from),
87                Some(skip_disjoint_check),
88            )
89        })
90        .map(|path| path.to_string_lossy().to_string())
91        .map_err(|e| PyIOError::new_err(format!("Failed to write {label}: {e}")))
92}
93
94fn query_parquet_data<T>(
95    py: Python<'_>,
96    catalog: &mut ParquetDataCatalog,
97    identifiers: Option<Vec<String>>,
98    start: Option<u64>,
99    end: Option<u64>,
100    where_clause: Option<&str>,
101    files: Option<Vec<String>>,
102    optimize_file_loading: bool,
103    error_context: &str,
104) -> PyResult<Vec<T>>
105where
106    T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit + Send,
107{
108    py.detach(move || {
109        catalog.query::<T>(
110            identifiers,
111            start.map(UnixNanos::from),
112            end.map(UnixNanos::from),
113            where_clause,
114            files,
115            optimize_file_loading,
116        )
117    })
118    .map_err(|e| PyIOError::new_err(format!("{error_context}: {e}")))
119}
120
121fn reject_parquet_as_of(as_of: Option<&Bound<'_, PyAny>>) -> PyResult<()> {
122    if as_of.is_some() {
123        return Err(to_pyvalue_err("ParquetDataCatalog does not support as_of"));
124    }
125
126    Ok(())
127}
128
129/// A catalog for writing data to Parquet files.
130#[pyclass(name = "ParquetDataCatalog", module = "nautilus_trader.persistence")]
131#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")]
132pub struct PyParquetDataCatalog {
133    inner: ParquetDataCatalog,
134    mutation_lock: Mutex<()>,
135}
136
137#[pymethods]
138#[pyo3_stub_gen::derive::gen_stub_pymethods]
139impl PyParquetDataCatalog {
140    /// Create a new `ParquetCatalog` with the given base path and optional parameters.
141    ///
142    /// # Parameters
143    ///
144    /// - `base_path`: The base path for the catalog
145    /// - `storage_options`: Optional storage configuration for cloud backends
146    /// - `batch_size`: Optional batch size for processing (default: 10,000)
147    /// - `compression`: Optional compression code (0=UNCOMPRESSED, 1=SNAPPY, 2=GZIP, 4=BROTLI, 5=`LZ4_RAW`, 6=ZSTD)
148    /// - `max_row_group_size`: Optional maximum row group size (default: 131,072)
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if `compression` is not one of the listed codes, `batch_size` or
153    /// `max_row_group_size` is zero, or the underlying [`ParquetDataCatalog`] cannot be created.
154    #[new]
155    #[pyo3(signature = (base_path, storage_options=None, batch_size=None, compression=None, max_row_group_size=None))]
156    pub fn py_new(
157        base_path: &str,
158        storage_options: Option<HashMap<String, String>>,
159        batch_size: Option<usize>,
160        compression: Option<u8>,
161        max_row_group_size: Option<usize>,
162    ) -> PyResult<Self> {
163        let compression = compression
164            .map(catalog_compression_from_code)
165            .transpose()?
166            .map(parquet::basic::Compression::from);
167
168        // Convert HashMap to AHashMap for internal use
169        let storage_options = storage_options.map(|m| m.into_iter().collect());
170
171        let inner = ParquetDataCatalog::from_uri(
172            base_path,
173            storage_options,
174            batch_size,
175            compression,
176            max_row_group_size,
177        )
178        .map_err(|e| match e.downcast::<ConfigError>() {
179            Ok(e) => config_error_to_pyvalue_err(e),
180            Err(e) => PyIOError::new_err(format!("Failed to create ParquetDataCatalog: {e}")),
181        })?;
182
183        Ok(Self {
184            inner,
185            mutation_lock: Mutex::new(()),
186        })
187    }
188
189    /// Rewrites a legacy Parquet catalog into this current Parquet catalog.
190    #[pyo3(signature = (parquet_path, storage_options=None, dry_run=false))]
191    pub fn migrate_from_legacy_parquet_path(
192        mut slf: PyRefMut<'_, Self>,
193        parquet_path: &str,
194        storage_options: Option<HashMap<String, String>>,
195        dry_run: bool,
196    ) -> PyResult<usize> {
197        let storage_options = storage_options.map(|m| m.into_iter().collect());
198        let source = ParquetDataCatalog::from_uri(parquet_path, storage_options, None, None, None)
199            .map_err(to_pyio_err)?;
200        let py = slf.py();
201        if dry_run {
202            return py
203                .detach(|| {
204                    let plan = build_catalog_migration_plan(&source)?;
205                    plan.ensure_ready()?;
206                    Ok::<usize, anyhow::Error>(0)
207                })
208                .map_err(to_pyio_err);
209        }
210
211        let inner = &mut slf.inner;
212        py.detach(|| {
213            inner
214                .migrate_from_legacy_parquet_catalog(&source)
215                .map(|report| report.migrated_rows)
216        })
217        .map_err(to_pyio_err)
218    }
219
220    /// Write quote tick data to Parquet files.
221    ///
222    /// # Parameters
223    ///
224    /// - `data`: Vector of quote ticks to write
225    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
226    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
227    ///
228    /// # Returns
229    ///
230    /// Returns the path of the created file as a string.
231    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
232    pub fn write_quote_ticks(
233        &self,
234        py: Python<'_>,
235        data: Vec<QuoteTick>,
236        start: Option<u64>,
237        end: Option<u64>,
238        skip_disjoint_check: bool,
239    ) -> PyResult<String> {
240        write_parquet_data(
241            py,
242            self,
243            data,
244            start,
245            end,
246            skip_disjoint_check,
247            "quote ticks",
248        )
249    }
250
251    /// Write trade tick data to Parquet files.
252    ///
253    /// # Parameters
254    ///
255    /// - `data`: Vector of trade ticks to write
256    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
257    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
258    ///
259    /// # Returns
260    ///
261    /// Returns the path of the created file as a string.
262    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
263    pub fn write_trade_ticks(
264        &self,
265        py: Python<'_>,
266        data: Vec<TradeTick>,
267        start: Option<u64>,
268        end: Option<u64>,
269        skip_disjoint_check: bool,
270    ) -> PyResult<String> {
271        write_parquet_data(
272            py,
273            self,
274            data,
275            start,
276            end,
277            skip_disjoint_check,
278            "trade ticks",
279        )
280    }
281
282    /// Write order book delta data to Parquet files.
283    ///
284    /// # Parameters
285    ///
286    /// - `data`: Vector of order book deltas to write
287    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
288    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
289    ///
290    /// # Returns
291    ///
292    /// Returns the path of the created file as a string.
293    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
294    pub fn write_order_book_deltas(
295        &self,
296        py: Python<'_>,
297        data: Vec<OrderBookDelta>,
298        start: Option<u64>,
299        end: Option<u64>,
300        skip_disjoint_check: bool,
301    ) -> PyResult<String> {
302        write_parquet_data(
303            py,
304            self,
305            data,
306            start,
307            end,
308            skip_disjoint_check,
309            "order book deltas",
310        )
311    }
312
313    /// Write bar data to Parquet files.
314    ///
315    /// # Parameters
316    ///
317    /// - `data`: Vector of bars to write
318    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
319    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
320    ///
321    /// # Returns
322    ///
323    /// Returns the path of the created file as a string.
324    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
325    pub fn write_bars(
326        &self,
327        py: Python<'_>,
328        data: Vec<Bar>,
329        start: Option<u64>,
330        end: Option<u64>,
331        skip_disjoint_check: bool,
332    ) -> PyResult<String> {
333        write_parquet_data(py, self, data, start, end, skip_disjoint_check, "bars")
334    }
335
336    /// Write order book depth data to Parquet files.
337    ///
338    /// # Parameters
339    ///
340    /// - `data`: Vector of order book depths 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_order_book_depths(
349        &self,
350        py: Python<'_>,
351        data: Vec<OrderBookDepth>,
352        start: Option<u64>,
353        end: Option<u64>,
354        skip_disjoint_check: bool,
355    ) -> PyResult<String> {
356        write_parquet_data(
357            py,
358            self,
359            data,
360            start,
361            end,
362            skip_disjoint_check,
363            "order book depths",
364        )
365    }
366
367    /// Write mark price update data to Parquet files.
368    ///
369    /// # Parameters
370    ///
371    /// - `data`: Vector of mark price updates to write
372    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
373    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
374    ///
375    /// # Returns
376    ///
377    /// Returns the path of the created file as a string.
378    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
379    pub fn write_mark_price_updates(
380        &self,
381        py: Python<'_>,
382        data: Vec<MarkPriceUpdate>,
383        start: Option<u64>,
384        end: Option<u64>,
385        skip_disjoint_check: bool,
386    ) -> PyResult<String> {
387        write_parquet_data(
388            py,
389            self,
390            data,
391            start,
392            end,
393            skip_disjoint_check,
394            "mark price updates",
395        )
396    }
397
398    /// Write index price update data to Parquet files.
399    ///
400    /// # Parameters
401    ///
402    /// - `data`: Vector of index price updates to write
403    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
404    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
405    ///
406    /// # Returns
407    ///
408    /// Returns the path of the created file as a string.
409    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
410    pub fn write_index_price_updates(
411        &self,
412        py: Python<'_>,
413        data: Vec<IndexPriceUpdate>,
414        start: Option<u64>,
415        end: Option<u64>,
416        skip_disjoint_check: bool,
417    ) -> PyResult<String> {
418        write_parquet_data(
419            py,
420            self,
421            data,
422            start,
423            end,
424            skip_disjoint_check,
425            "index price updates",
426        )
427    }
428
429    /// Write option greeks data to Parquet files.
430    ///
431    /// # Parameters
432    ///
433    /// - `data`: Vector of option greeks to write
434    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
435    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
436    ///
437    /// # Returns
438    ///
439    /// Returns the path of the created file as a string.
440    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
441    pub fn write_option_greeks(
442        &self,
443        py: Python<'_>,
444        data: Vec<OptionGreeks>,
445        start: Option<u64>,
446        end: Option<u64>,
447        skip_disjoint_check: bool,
448    ) -> PyResult<String> {
449        write_parquet_data(
450            py,
451            self,
452            data,
453            start,
454            end,
455            skip_disjoint_check,
456            "option greeks",
457        )
458    }
459
460    /// Write instrument status data to Parquet files.
461    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
462    pub fn write_instrument_statuses(
463        &self,
464        py: Python<'_>,
465        data: Vec<InstrumentStatus>,
466        start: Option<u64>,
467        end: Option<u64>,
468        skip_disjoint_check: bool,
469    ) -> PyResult<String> {
470        write_parquet_data(
471            py,
472            self,
473            data,
474            start,
475            end,
476            skip_disjoint_check,
477            "instrument statuses",
478        )
479    }
480
481    /// Write instrument close data to Parquet files.
482    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
483    pub fn write_instrument_closes(
484        &self,
485        py: Python<'_>,
486        data: Vec<InstrumentClose>,
487        start: Option<u64>,
488        end: Option<u64>,
489        skip_disjoint_check: bool,
490    ) -> PyResult<String> {
491        write_parquet_data(
492            py,
493            self,
494            data,
495            start,
496            end,
497            skip_disjoint_check,
498            "instrument closes",
499        )
500    }
501
502    /// Write instruments to Parquet files in the catalog.
503    ///
504    /// Instruments are stored under `data/instruments/{instrument_id}/` using timestamp-ranged
505    /// parquet file names, allowing multiple historical versions of the same instrument to be
506    /// written across separate calls.
507    ///
508    /// # Parameters
509    ///
510    /// - `instruments`: A Python list of instrument objects (e.g. `CurrencyPair`, Equity).
511    ///
512    /// # Returns
513    ///
514    /// Returns a list of written file paths.
515    pub fn write_instruments(&self, instruments: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
516        let data = instruments;
517        let py = data.py();
518        let list = data.cast::<PyList>()?;
519        let mut instruments = Vec::with_capacity(list.len());
520        for item in list.iter() {
521            let py_item: Py<PyAny> = item.unbind();
522            let instrument = pyobject_to_instrument_any(py, py_item)?;
523            instruments.push(instrument);
524        }
525
526        self.mutate(py, || self.inner.write_instruments(instruments))
527            .map(|paths| {
528                paths
529                    .into_iter()
530                    .map(|p| p.to_string_lossy().to_string())
531                    .collect()
532            })
533            .map_err(|e| PyIOError::new_err(format!("Failed to write instruments: {e}")))
534    }
535
536    /// Query instruments from the catalog.
537    ///
538    /// # Parameters
539    ///
540    /// - `instrument_ids`: Optional list of instrument IDs to filter by. If `None`, returns all instruments.
541    /// - `start`: Optional inclusive lower bound for `ts_init` filtering.
542    /// - `end`: Optional inclusive upper bound for `ts_init` filtering.
543    /// - `where_clause`: Optional SQL WHERE clause for additional filtering.
544    ///
545    /// # Returns
546    ///
547    /// Returns a list of instrument objects (e.g. `CurrencyPair`, Equity).
548    #[pyo3(signature = (
549        instrument_ids=None,
550        start=None,
551        end=None,
552        where_clause=None,
553        instrument_type=None,
554    ))]
555    #[expect(clippy::needless_pass_by_value)]
556    pub fn instruments(
557        &mut self,
558        py: Python<'_>,
559        instrument_ids: Option<Vec<String>>,
560        start: Option<u64>,
561        end: Option<u64>,
562        where_clause: Option<&str>,
563        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
564    ) -> PyResult<Vec<Py<PyAny>>> {
565        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
566
567        let rust_instruments = py
568            .detach(|| {
569                self.inner.query_instruments_filtered_with_where_and_type(
570                    instrument_ids.as_deref(),
571                    start.map(UnixNanos::from),
572                    end.map(UnixNanos::from),
573                    where_clause,
574                    instrument_type.as_ref(),
575                )
576            })
577            .map_err(|e| PyIOError::new_err(format!("Failed to query instruments: {e}")))?;
578
579        rust_instruments
580            .into_iter()
581            .map(|inst| instrument_any_to_pyobject(py, inst))
582            .collect()
583    }
584
585    /// Query instruments as display-friendly Arrow IPC stream bytes.
586    #[pyo3(signature = (
587        instrument_ids=None,
588        start=None,
589        end=None,
590        where_clause=None,
591        instrument_type=None,
592    ))]
593    #[expect(clippy::needless_pass_by_value)]
594    pub fn query_instrument_arrow_bytes(
595        &mut self,
596        py: Python<'_>,
597        instrument_ids: Option<Vec<String>>,
598        start: Option<u64>,
599        end: Option<u64>,
600        where_clause: Option<&str>,
601        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
602    ) -> PyResult<Py<PyBytes>> {
603        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
604
605        let instruments = py
606            .detach(|| {
607                self.inner.query_instruments_filtered_with_where_and_type(
608                    instrument_ids.as_deref(),
609                    start.map(UnixNanos::from),
610                    end.map(UnixNanos::from),
611                    where_clause,
612                    instrument_type.as_ref(),
613                )
614            })
615            .map_err(|e| PyIOError::new_err(format!("Failed query instruments: {e}")))?;
616
617        let batch = encode_instruments(&instruments)
618            .map_err(|e| PyIOError::new_err(format!("Failed encode instruments: {e}")))?;
619        let schema = batch.schema().as_ref().clone();
620        arrow_record_batches_to_pybytes(py, &schema, &[batch])
621    }
622
623    /// Query instruments as an Arrow C stream `PyCapsule`.
624    #[pyo3(signature = (
625        instrument_ids=None,
626        start=None,
627        end=None,
628        where_clause=None,
629        instrument_type=None,
630    ))]
631    #[expect(clippy::needless_pass_by_value)]
632    pub fn query_instrument_arrow_stream(
633        &mut self,
634        py: Python<'_>,
635        instrument_ids: Option<Vec<String>>,
636        start: Option<u64>,
637        end: Option<u64>,
638        where_clause: Option<&str>,
639        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
640    ) -> PyResult<Py<PyAny>> {
641        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
642
643        let instruments = py
644            .detach(|| {
645                self.inner.query_instruments_filtered_with_where_and_type(
646                    instrument_ids.as_deref(),
647                    start.map(UnixNanos::from),
648                    end.map(UnixNanos::from),
649                    where_clause,
650                    instrument_type.as_ref(),
651                )
652            })
653            .map_err(|e| PyIOError::new_err(format!("Failed query instruments: {e}")))?;
654
655        let batch = encode_instruments(&instruments)
656            .map_err(|e| PyIOError::new_err(format!("Failed encode instruments: {e}")))?;
657        let schema = batch.schema().as_ref().clone();
658        arrow_record_batches_to_pyarrow_stream(py, &schema, vec![batch])
659    }
660
661    /// Extend file names in the catalog with additional timestamp information.
662    ///
663    /// # Parameters
664    ///
665    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
666    /// - `identifier`: Optional instrument ID or bar type filter
667    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
668    /// - `end`: End timestamp (nanoseconds since Unix epoch)
669    #[pyo3(signature = (data_type, identifier=None, *, start, end))]
670    #[expect(clippy::needless_pass_by_value)]
671    pub fn extend_file_name(
672        &self,
673        py: Python<'_>,
674        data_type: PyCatalogDataType,
675        identifier: Option<String>,
676        start: u64,
677        end: u64,
678    ) -> PyResult<()> {
679        let data_type = data_type.into_inner();
680        let start_nanos = UnixNanos::from(start);
681        let end_nanos = UnixNanos::from(end);
682
683        self.mutate(py, || {
684            self.inner
685                .extend_file_name(&data_type, identifier.as_deref(), start_nanos, end_nanos)
686        })
687        .map_err(|e| PyIOError::new_err(format!("Failed to extend file name: {e}")))
688    }
689
690    /// Consolidate all data files in the catalog within the specified time range.
691    ///
692    /// # Parameters
693    ///
694    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
695    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
696    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
697    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
698    #[pyo3(signature = (start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
699    pub fn consolidate_catalog(
700        &self,
701        py: Python<'_>,
702        start: Option<u64>,
703        end: Option<u64>,
704        ensure_contiguous_files: Option<bool>,
705        deduplicate: Option<bool>,
706    ) -> PyResult<()> {
707        let start_nanos = start.map(UnixNanos::from);
708        let end_nanos = end.map(UnixNanos::from);
709
710        self.mutate(py, || {
711            self.inner.consolidate_catalog(
712                start_nanos,
713                end_nanos,
714                ensure_contiguous_files,
715                deduplicate,
716            )
717        })
718        .map_err(|e| PyIOError::new_err(format!("Failed to consolidate catalog: {e}")))
719    }
720
721    /// Consolidate data files for a specific data type within the specified time range.
722    ///
723    /// # Parameters
724    ///
725    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
726    /// - `identifier`: Optional instrument ID or bar type filter
727    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
728    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
729    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
730    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
731    #[pyo3(signature = (data_type, identifier=None, start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
732    #[expect(clippy::needless_pass_by_value)]
733    pub fn consolidate_data(
734        &mut self,
735        py: Python<'_>,
736        data_type: PyCatalogDataType,
737        identifier: Option<String>,
738        start: Option<u64>,
739        end: Option<u64>,
740        ensure_contiguous_files: Option<bool>,
741        deduplicate: Option<bool>,
742    ) -> PyResult<()> {
743        let data_type = data_type.into_inner();
744        let start_nanos = start.map(UnixNanos::from);
745        let end_nanos = end.map(UnixNanos::from);
746
747        py.detach(|| {
748            self.inner.consolidate_data(
749                &data_type,
750                identifier.as_deref(),
751                start_nanos,
752                end_nanos,
753                ensure_contiguous_files,
754                deduplicate,
755            )
756        })
757        .map_err(|e| PyIOError::new_err(format!("Failed to consolidate data: {e}")))
758    }
759
760    /// Consolidate all data files in the catalog by splitting them into fixed time periods.
761    ///
762    /// This method identifies all leaf directories in the catalog that contain parquet files
763    /// and consolidates them by period. A leaf directory is one that contains files but no subdirectories.
764    /// This is a convenience method that effectively calls `consolidate_data_by_period` for all data types
765    /// and instrument IDs in the catalog.
766    ///
767    /// # Parameters
768    ///
769    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
770    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
771    /// - `start`: Optional start timestamp for the consolidation range (nanoseconds since Unix epoch)
772    /// - `end`: Optional end timestamp for the consolidation range (nanoseconds since Unix epoch)
773    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
774    #[pyo3(signature = (period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
775    pub fn consolidate_catalog_by_period(
776        &mut self,
777        period_nanos: Option<u64>,
778        start: Option<u64>,
779        end: Option<u64>,
780        ensure_contiguous_files: Option<bool>,
781    ) -> PyResult<()> {
782        let start_nanos = start.map(UnixNanos::from);
783        let end_nanos = end.map(UnixNanos::from);
784
785        self.inner
786            .consolidate_catalog_by_period(
787                period_nanos,
788                start_nanos,
789                end_nanos,
790                ensure_contiguous_files,
791            )
792            .map_err(|e| {
793                PyIOError::new_err(format!("Failed to consolidate catalog by period: {e}"))
794            })
795    }
796
797    /// Consolidate data files by splitting them into fixed time periods.
798    ///
799    /// This method queries data by period and writes consolidated files immediately,
800    /// using efficient period-based consolidation logic. When start/end boundaries intersect existing files,
801    /// the function automatically splits those files to preserve all data.
802    ///
803    /// # Parameters
804    ///
805    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
806    /// - `identifier`: Optional instrument ID to consolidate. If None, consolidates all instruments
807    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
808    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
809    /// - `start`: Optional start timestamp for consolidation range (nanoseconds since Unix epoch)
810    /// - `end`: Optional end timestamp for consolidation range (nanoseconds since Unix epoch)
811    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
812    #[pyo3(signature = (data_type, identifier=None, period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
813    #[expect(clippy::needless_pass_by_value)]
814    pub fn consolidate_data_by_period(
815        &mut self,
816        py: Python<'_>,
817        data_type: PyCatalogDataType,
818        identifier: Option<String>,
819        period_nanos: Option<u64>,
820        start: Option<u64>,
821        end: Option<u64>,
822        ensure_contiguous_files: Option<bool>,
823    ) -> PyResult<()> {
824        let data_type = data_type.into_inner();
825        let start_nanos = start.map(UnixNanos::from);
826        let end_nanos = end.map(UnixNanos::from);
827
828        let mut consolidate = || {
829            self.inner.consolidate_data_by_period(
830                &data_type,
831                identifier.as_deref(),
832                period_nanos,
833                start_nanos,
834                end_nanos,
835                ensure_contiguous_files,
836            )
837        };
838
839        let result = if matches!(
840            &data_type,
841            CatalogDataType::Data(NautilusDataType::Custom { .. })
842        ) {
843            consolidate()
844        } else {
845            py.detach(consolidate)
846        };
847
848        result.map_err(|e| PyIOError::new_err(format!("Failed to consolidate data by period: {e}")))
849    }
850
851    /// Reset all catalog file names to their canonical form.
852    pub fn reset_all_file_names(&self, py: Python<'_>) -> PyResult<()> {
853        self.mutate(py, || self.inner.reset_all_file_names())
854            .map_err(|e| PyIOError::new_err(format!("Failed to reset catalog file names: {e}")))
855    }
856
857    /// Reset data file names for a specific data class to their canonical form.
858    ///
859    /// # Parameters
860    ///
861    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
862    /// - `identifier`: Optional instrument ID or bar type filter
863    #[pyo3(signature = (data_type, identifier=None))]
864    #[expect(clippy::needless_pass_by_value)]
865    pub fn reset_data_file_names(
866        &self,
867        py: Python<'_>,
868        data_type: PyCatalogDataType,
869        identifier: Option<String>,
870    ) -> PyResult<()> {
871        let data_type = data_type.into_inner();
872        self.mutate(py, || {
873            self.inner
874                .reset_data_file_names(&data_type, identifier.as_deref())
875        })
876        .map_err(|e| PyIOError::new_err(format!("Failed to reset data file names: {e}")))
877    }
878
879    /// Delete data within a specified time range across the entire catalog.
880    ///
881    /// This method identifies all leaf directories in the catalog that contain parquet files
882    /// and deletes data within the specified time range from each directory. A leaf directory
883    /// is one that contains files but no subdirectories. This is a convenience method that
884    /// effectively calls `delete_data_range` for all data types and instrument IDs in the catalog.
885    ///
886    /// # Parameters
887    ///
888    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
889    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
890    ///
891    /// # Notes
892    ///
893    /// - This operation permanently removes data and cannot be undone
894    /// - The deletion process handles file intersections intelligently by splitting files
895    ///   when they partially overlap with the deletion range
896    /// - Files completely within the deletion range are removed entirely
897    /// - Files partially overlapping the deletion range are split to preserve data outside the range
898    /// - This method is useful for bulk data cleanup operations across the entire catalog
899    /// - Empty directories are not automatically removed after deletion
900    #[pyo3(signature = (start=None, end=None))]
901    pub fn delete_catalog_range(&mut self, start: Option<u64>, end: Option<u64>) -> PyResult<()> {
902        let start_nanos = start.map(UnixNanos::from);
903        let end_nanos = end.map(UnixNanos::from);
904
905        self.inner
906            .delete_catalog_range(start_nanos, end_nanos)
907            .map_err(|e| PyIOError::new_err(format!("Failed to delete catalog range: {e}")))
908    }
909
910    /// Delete data within a specified time range for a specific data type and instrument.
911    ///
912    /// This method identifies all parquet files that intersect with the specified time range
913    /// and handles them appropriately:
914    /// - Files completely within the range are deleted
915    /// - Files partially overlapping the range are split to preserve data outside the range
916    /// - The original intersecting files are removed after processing
917    ///
918    /// # Parameters
919    ///
920    /// - `data_type`: The data type to delete from.
921    /// - `identifier`: Optional identifier to delete data for. If None, deletes data across all identifiers
922    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
923    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
924    ///
925    /// # Notes
926    ///
927    /// - This operation permanently removes data and cannot be undone
928    /// - Files that partially overlap the deletion range are split to preserve data outside the range
929    /// - The method ensures data integrity by using atomic operations where possible
930    /// - Empty directories are not automatically removed after deletion
931    #[pyo3(signature = (data_type, identifier=None, start=None, end=None))]
932    #[expect(clippy::needless_pass_by_value)]
933    pub fn delete_data_range(
934        &mut self,
935        py: Python<'_>,
936        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
937            '_,
938            PyAny,
939        >,
940        identifier: Option<String>,
941        start: Option<u64>,
942        end: Option<u64>,
943    ) -> PyResult<()> {
944        let data_type = nautilus_data_type_from_py(data_type)?;
945        let start_nanos = start.map(UnixNanos::from);
946        let end_nanos = end.map(UnixNanos::from);
947
948        let mut delete = || {
949            self.inner
950                .delete_data_range(&data_type, identifier.as_deref(), start_nanos, end_nanos)
951        };
952
953        let result = if matches!(&data_type, NautilusDataType::Custom { .. }) {
954            delete()
955        } else {
956            py.detach(delete)
957        };
958
959        result.map_err(|e| PyIOError::new_err(format!("Failed to delete data range: {e}")))
960    }
961
962    /// Writes custom data to Parquet files.
963    ///
964    /// Requires `CustomData` wrappers. Callers must wrap raw custom objects in
965    /// `CustomData(data_type=DataType(cls, metadata=...), data=...)` before writing.
966    ///
967    /// The registered Arrow schema must contain `ts_init`. Any `ts_event` or `ts_init` fields
968    /// must use `timestamp("ns", tz="UTC")`; incompatible schemas fail before writing.
969    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
970    pub fn write_custom_data(
971        &self,
972        py: Python<'_>,
973        data: Vec<Bound<'_, PyAny>>,
974        start: Option<u64>,
975        end: Option<u64>,
976        skip_disjoint_check: bool,
977    ) -> PyResult<String> {
978        use nautilus_model::data::CustomData;
979
980        let mut custom_items: Vec<CustomData> = Vec::with_capacity(data.len());
981        for obj in data {
982            let custom = obj.extract::<CustomData>().map_err(|_| {
983                to_pytype_err(
984                    "write_custom_data requires CustomData wrappers; wrap with CustomData(data_type=DataType(cls, metadata=...), data=...)",
985                )
986            })?;
987
988            custom_items.push(custom);
989        }
990
991        if custom_items.is_empty() {
992            return Ok(String::new());
993        }
994
995        let start_nanos = start.map(UnixNanos::from);
996        let end_nanos = end.map(UnixNanos::from);
997        let write_error =
998            |e: anyhow::Error| PyIOError::new_err(format!("Failed to write custom data: {e}"));
999        let custom_refs = custom_items.iter().collect::<Vec<_>>();
1000        let write = self
1001            .inner
1002            .prepare_custom_data_write(&custom_refs, start_nanos, end_nanos)
1003            .map_err(write_error)?;
1004
1005        self.mutate(py, || {
1006            self.inner
1007                .write_custom_data_prepared(&write, Some(skip_disjoint_check))
1008        })
1009        .map(|path| path.to_string_lossy().to_string())
1010        .map_err(write_error)
1011    }
1012
1013    /// List all instrument IDs available in the catalog for a given catalog type.
1014    pub fn list_instruments(
1015        &self,
1016        py: Python<'_>,
1017        data_type: PyCatalogDataType,
1018    ) -> PyResult<Vec<String>> {
1019        let data_type = data_type.into_inner();
1020        py.detach(|| self.inner.list_instruments(&data_type))
1021            .map_err(|e| PyIOError::new_err(format!("Failed to list instruments: {e}")))
1022    }
1023
1024    /// List all Parquet files in the catalog for a given data type and identifier.
1025    pub fn list_parquet_files(
1026        &self,
1027        py: Python<'_>,
1028        data_type: PyCatalogDataType,
1029        identifier: &str,
1030    ) -> PyResult<Vec<String>> {
1031        let data_type = data_type.into_inner();
1032        let list_files = |prefix: &str| -> PyResult<Vec<String>> {
1033            let directory = self
1034                .inner
1035                .make_path(prefix, Some(identifier))
1036                .map_err(|e| PyIOError::new_err(format!("Failed to list parquet files: {e}")))?;
1037            py.detach(|| self.inner.list_parquet_files(&directory))
1038                .map_err(|e| PyIOError::new_err(format!("Failed to list parquet files: {e}")))
1039        };
1040        let mut files = Vec::new();
1041
1042        for prefix in parquet_catalog_data_type_path_prefixes(&data_type) {
1043            files.extend(list_files(prefix.as_ref())?);
1044        }
1045
1046        Ok(files)
1047    }
1048
1049    /// Query files in the catalog matching the specified criteria.
1050    ///
1051    /// # Parameters
1052    ///
1053    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1054    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1055    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1056    ///   For bars, partial matching is supported.
1057    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1058    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1059    ///
1060    /// # Returns
1061    ///
1062    /// Returns a list of file paths matching the criteria.
1063    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None))]
1064    pub fn query_files(
1065        &self,
1066        py: Python<'_>,
1067        data_type: PyCatalogDataType,
1068        identifiers: Option<Vec<String>>,
1069        start: Option<u64>,
1070        end: Option<u64>,
1071    ) -> PyResult<Vec<String>> {
1072        let data_type = data_type.into_inner();
1073        let start_nanos = start.map(UnixNanos::from);
1074        let end_nanos = end.map(UnixNanos::from);
1075
1076        py.detach(|| {
1077            self.inner
1078                .query_files(&data_type, identifiers, start_nanos, end_nanos)
1079        })
1080        .map_err(|e| PyIOError::new_err(format!("Failed to query files list: {e}")))
1081    }
1082
1083    /// Query catalog metadata keyed by the first timestamp where each metadata is used.
1084    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None))]
1085    pub fn query_metadata(
1086        &mut self,
1087        py: Python<'_>,
1088        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1089            '_,
1090            PyAny,
1091        >,
1092        identifiers: Option<Vec<String>>,
1093        start: Option<u64>,
1094        end: Option<u64>,
1095        where_clause: Option<&str>,
1096    ) -> PyResult<Py<PyDict>> {
1097        let data_type = nautilus_data_type_from_py(data_type)?;
1098
1099        let metadata = py
1100            .detach(|| {
1101                CatalogReader::query_metadata(
1102                    &mut self.inner,
1103                    &CatalogQuery::new(data_type)
1104                        .with_identifiers(identifiers)
1105                        .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1106                        .with_where_clause(where_clause.map(str::to_string)),
1107                )
1108            })
1109            .map_err(|e| PyIOError::new_err(format!("Metadata query failed: {e}")))?;
1110
1111        catalog_metadata_to_pydict(py, metadata)
1112    }
1113
1114    /// Query catalog data as display-friendly Arrow IPC stream bytes.
1115    #[pyo3(signature = (
1116        data_type,
1117        identifiers=None,
1118        start=None,
1119        end=None,
1120        where_clause=None,
1121        display=true,
1122        as_of=None,
1123    ))]
1124    pub fn query_data_arrow_bytes(
1125        &mut self,
1126        py: Python<'_>,
1127        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1128            '_,
1129            PyAny,
1130        >,
1131        identifiers: Option<Vec<String>>,
1132        start: Option<u64>,
1133        end: Option<u64>,
1134        where_clause: Option<&str>,
1135        display: bool,
1136        as_of: Option<&Bound<'_, PyAny>>,
1137    ) -> PyResult<Py<PyBytes>> {
1138        let data_type = nautilus_data_type_from_py(data_type)?;
1139        reject_parquet_as_of(as_of)?;
1140        let query = CatalogQuery::new(data_type.clone())
1141            .with_identifiers(identifiers)
1142            .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1143            .with_where_clause(where_clause.map(str::to_string));
1144
1145        let batches = py
1146            .detach(|| {
1147                if display {
1148                    CatalogReader::query_display_record_batches(&mut self.inner, &query)
1149                } else {
1150                    let data = CatalogReader::query_batch(&mut self.inner, &query)?
1151                        .to_data_vec_for_compat();
1152                    crate::common::arrow::data_to_arrow_batches(&data_type, data)
1153                }
1154            })
1155            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1156
1157        let schema = arrow_ipc_data_schema(&data_type, &batches, display)?;
1158        let batches = arrow_ipc_batches(&schema, batches)?;
1159        arrow_record_batches_to_pybytes(py, &schema, &batches)
1160    }
1161
1162    /// Query catalog data as an Arrow C stream `PyCapsule`.
1163    #[pyo3(signature = (
1164        data_type,
1165        identifiers=None,
1166        start=None,
1167        end=None,
1168        where_clause=None,
1169        display=true,
1170        as_of=None,
1171    ))]
1172    pub fn query_data_arrow_stream(
1173        &mut self,
1174        py: Python<'_>,
1175        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1176            '_,
1177            PyAny,
1178        >,
1179        identifiers: Option<Vec<String>>,
1180        start: Option<u64>,
1181        end: Option<u64>,
1182        where_clause: Option<&str>,
1183        display: bool,
1184        as_of: Option<&Bound<'_, PyAny>>,
1185    ) -> PyResult<Py<PyAny>> {
1186        let data_type = nautilus_data_type_from_py(data_type)?;
1187        reject_parquet_as_of(as_of)?;
1188        let query = CatalogQuery::new(data_type.clone())
1189            .with_identifiers(identifiers)
1190            .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1191            .with_where_clause(where_clause.map(str::to_string));
1192
1193        let batches = py
1194            .detach(|| {
1195                if display {
1196                    CatalogReader::query_display_record_batches(&mut self.inner, &query)
1197                } else {
1198                    let data = CatalogReader::query_batch(&mut self.inner, &query)?
1199                        .to_data_vec_for_compat();
1200                    crate::common::arrow::data_to_arrow_batches(&data_type, data)
1201                }
1202            })
1203            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1204
1205        let schema = arrow_ipc_data_schema(&data_type, &batches, display)?;
1206        let batches = arrow_ipc_batches(&schema, batches)?;
1207        arrow_record_batches_to_pyarrow_stream(py, &schema, batches)
1208    }
1209
1210    /// Write catalog records from Arrow IPC stream bytes.
1211    #[pyo3(signature = (record_type, data, identifier=None, params=None))]
1212    pub fn write_record_arrow_bytes(
1213        &mut self,
1214        py: Python<'_>,
1215        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1216            '_,
1217            PyAny,
1218        >,
1219        data: Vec<u8>,
1220        identifier: Option<String>,
1221        params: Option<Py<PyDict>>,
1222    ) -> PyResult<()> {
1223        let record_type = catalog_record_type_from_py(record_type)?;
1224        let batches = arrow_record_batches_from_pybytes(data)?;
1225        let params = write_record_params_from_py(py, identifier, params)?;
1226
1227        py.detach(|| CatalogWriter::write_records(&mut self.inner, record_type, &batches, params))
1228            .map_err(|e| PyIOError::new_err(format!("Failed write records: {e}")))
1229    }
1230
1231    /// Query catalog records and return Arrow IPC stream bytes.
1232    #[pyo3(signature = (
1233        record_type,
1234        identifier=None,
1235        start=None,
1236        end=None,
1237        where_clause=None,
1238        display=true,
1239        as_of=None,
1240    ))]
1241    pub fn query_record_arrow_bytes(
1242        &mut self,
1243        py: Python<'_>,
1244        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1245            '_,
1246            PyAny,
1247        >,
1248        identifier: Option<String>,
1249        start: Option<u64>,
1250        end: Option<u64>,
1251        where_clause: Option<&str>,
1252        display: bool,
1253        as_of: Option<&Bound<'_, PyAny>>,
1254    ) -> PyResult<Py<PyBytes>> {
1255        let record_type = catalog_record_type_from_py(record_type)?;
1256        reject_parquet_as_of(as_of)?;
1257        let start_nanos = start.map(UnixNanos::from);
1258        let end_nanos = end.map(UnixNanos::from);
1259
1260        let batches = py
1261            .detach(|| {
1262                if display {
1263                    CatalogReader::query_record_display_batches(
1264                        &mut self.inner,
1265                        &CatalogRecordQuery::new(record_type)
1266                            .with_identifier(identifier)
1267                            .with_range(start_nanos, end_nanos)
1268                            .with_where_clause(where_clause.map(str::to_string)),
1269                    )
1270                } else {
1271                    CatalogReader::query_record_batches(
1272                        &mut self.inner,
1273                        &CatalogRecordQuery::new(record_type)
1274                            .with_identifier(identifier)
1275                            .with_range(start_nanos, end_nanos)
1276                            .with_where_clause(where_clause.map(str::to_string)),
1277                    )
1278                }
1279            })
1280            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1281
1282        let schema = arrow_ipc_record_schema(record_type, &batches)?;
1283        let batches = arrow_ipc_batches(&schema, batches)?;
1284
1285        arrow_record_batches_to_pybytes(py, &schema, &batches)
1286    }
1287
1288    /// Query catalog records as an Arrow C stream `PyCapsule`.
1289    #[pyo3(signature = (
1290        record_type,
1291        identifier=None,
1292        start=None,
1293        end=None,
1294        where_clause=None,
1295        display=true,
1296        as_of=None,
1297    ))]
1298    pub fn query_record_arrow_stream(
1299        &mut self,
1300        py: Python<'_>,
1301        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1302            '_,
1303            PyAny,
1304        >,
1305        identifier: Option<String>,
1306        start: Option<u64>,
1307        end: Option<u64>,
1308        where_clause: Option<&str>,
1309        display: bool,
1310        as_of: Option<&Bound<'_, PyAny>>,
1311    ) -> PyResult<Py<PyAny>> {
1312        let record_type = catalog_record_type_from_py(record_type)?;
1313        reject_parquet_as_of(as_of)?;
1314        let start_nanos = start.map(UnixNanos::from);
1315        let end_nanos = end.map(UnixNanos::from);
1316
1317        let batches = py
1318            .detach(|| {
1319                if display {
1320                    CatalogReader::query_record_display_batches(
1321                        &mut self.inner,
1322                        &CatalogRecordQuery::new(record_type)
1323                            .with_identifier(identifier)
1324                            .with_range(start_nanos, end_nanos)
1325                            .with_where_clause(where_clause.map(str::to_string)),
1326                    )
1327                } else {
1328                    CatalogReader::query_record_batches(
1329                        &mut self.inner,
1330                        &CatalogRecordQuery::new(record_type)
1331                            .with_identifier(identifier)
1332                            .with_range(start_nanos, end_nanos)
1333                            .with_where_clause(where_clause.map(str::to_string)),
1334                    )
1335                }
1336            })
1337            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1338
1339        let schema = arrow_ipc_record_schema(record_type, &batches)?;
1340        let batches = arrow_ipc_batches(&schema, batches)?;
1341
1342        arrow_record_batches_to_pyarrow_stream(py, &schema, batches)
1343    }
1344
1345    /// Get missing time intervals for a data request.
1346    ///
1347    /// # Parameters
1348    ///
1349    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
1350    /// - `end`: End timestamp (nanoseconds since Unix epoch)
1351    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1352    /// - `identifier`: Optional instrument ID or bar type filter
1353    ///
1354    /// # Returns
1355    ///
1356    /// Returns a list of (start, end) timestamp tuples representing missing intervals.
1357    #[pyo3(signature = (start, end, data_type, identifier=None))]
1358    #[expect(clippy::needless_pass_by_value)]
1359    pub fn get_missing_intervals_for_request(
1360        &self,
1361        py: Python<'_>,
1362        start: u64,
1363        end: u64,
1364        data_type: PyCatalogDataType,
1365        identifier: Option<String>,
1366    ) -> PyResult<Vec<(u64, u64)>> {
1367        let data_type = data_type.into_inner();
1368        py.detach(|| {
1369            self.inner.get_missing_intervals_for_request(
1370                start,
1371                end,
1372                &data_type,
1373                identifier.as_deref(),
1374            )
1375        })
1376        .map_err(|e| PyIOError::new_err(format!("Failed to get missing intervals: {e}")))
1377    }
1378
1379    /// Query the first timestamp for a specific data class and instrument.
1380    ///
1381    /// # Parameters
1382    ///
1383    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1384    /// - `identifier`: Optional instrument ID or bar type filter
1385    ///
1386    /// # Returns
1387    ///
1388    /// Returns the first timestamp as nanoseconds since Unix epoch, or None if no data exists.
1389    #[pyo3(signature = (data_type, identifier=None))]
1390    #[expect(clippy::needless_pass_by_value)]
1391    pub fn query_first_timestamp(
1392        &self,
1393        py: Python<'_>,
1394        data_type: PyCatalogDataType,
1395        identifier: Option<String>,
1396    ) -> PyResult<Option<u64>> {
1397        let data_type = data_type.into_inner();
1398        py.detach(|| {
1399            self.inner
1400                .query_first_timestamp(&data_type, identifier.as_deref())
1401        })
1402        .map_err(|e| PyIOError::new_err(format!("Failed to query first timestamp: {e}")))
1403    }
1404
1405    /// Query the last timestamp for a specific data class and instrument.
1406    ///
1407    /// # Parameters
1408    ///
1409    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1410    /// - `identifier`: Optional instrument ID or bar type filter
1411    ///
1412    /// # Returns
1413    ///
1414    /// Returns the last timestamp as nanoseconds since Unix epoch, or None if no data exists.
1415    #[pyo3(signature = (data_type, identifier=None))]
1416    #[expect(clippy::needless_pass_by_value)]
1417    pub fn query_last_timestamp(
1418        &self,
1419        py: Python<'_>,
1420        data_type: PyCatalogDataType,
1421        identifier: Option<String>,
1422    ) -> PyResult<Option<u64>> {
1423        let data_type = data_type.into_inner();
1424        py.detach(|| {
1425            self.inner
1426                .query_last_timestamp(&data_type, identifier.as_deref())
1427        })
1428        .map_err(|e| PyIOError::new_err(format!("Failed to query last timestamp: {e}")))
1429    }
1430
1431    /// Get time intervals covered by data for a specific data class and instrument.
1432    ///
1433    /// # Parameters
1434    ///
1435    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1436    /// - `identifier`: Optional instrument ID or bar type filter
1437    ///
1438    /// # Returns
1439    ///
1440    /// Returns a list of (start, end) timestamp tuples representing covered intervals.
1441    #[pyo3(signature = (data_type, identifier=None))]
1442    #[expect(clippy::needless_pass_by_value)]
1443    pub fn get_intervals(
1444        &self,
1445        py: Python<'_>,
1446        data_type: PyCatalogDataType,
1447        identifier: Option<String>,
1448    ) -> PyResult<Vec<(u64, u64)>> {
1449        let data_type = data_type.into_inner();
1450        py.detach(|| self.inner.get_intervals(&data_type, identifier.as_deref()))
1451            .map_err(|e| PyIOError::new_err(format!("Failed to get intervals: {e}")))
1452    }
1453
1454    /// Queries one data family and returns the decoded Python objects.
1455    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None, files=None, optimize_file_loading=true))]
1456    #[expect(
1457        clippy::too_many_arguments,
1458        reason = "PyO3 signature mirrors the catalog query filters"
1459    )]
1460    pub fn query(
1461        &mut self,
1462        py: Python<'_>,
1463        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1464            '_,
1465            PyAny,
1466        >,
1467        identifiers: Option<Vec<String>>,
1468        start: Option<u64>,
1469        end: Option<u64>,
1470        where_clause: Option<&str>,
1471        files: Option<Vec<String>>,
1472        optimize_file_loading: bool,
1473    ) -> PyResult<Vec<Py<PyAny>>> {
1474        let data_type = nautilus_data_type_from_py(data_type)?;
1475        let start_nanos = start.map(UnixNanos::from);
1476        let end_nanos = end.map(UnixNanos::from);
1477
1478        macro_rules! typed {
1479            ($type:ty) => {
1480                query_parquet_data::<$type>(
1481                    py,
1482                    &mut self.inner,
1483                    identifiers,
1484                    start,
1485                    end,
1486                    where_clause,
1487                    files,
1488                    optimize_file_loading,
1489                    "Query failed",
1490                )?
1491                .into_iter()
1492                .map(Data::from)
1493                .collect::<Vec<Data>>()
1494            };
1495        }
1496
1497        let data = match data_type {
1498            NautilusDataType::QuoteTick => typed!(QuoteTick),
1499            NautilusDataType::TradeTick => typed!(TradeTick),
1500            NautilusDataType::Bar => typed!(Bar),
1501            NautilusDataType::OrderBookDelta => typed!(OrderBookDelta),
1502            NautilusDataType::OrderBookDepth => typed!(OrderBookDepth),
1503            NautilusDataType::IndexPriceUpdate => typed!(IndexPriceUpdate),
1504            NautilusDataType::MarkPriceUpdate => typed!(MarkPriceUpdate),
1505            NautilusDataType::FundingRateUpdate => typed!(FundingRateUpdate),
1506            NautilusDataType::OptionGreeks => typed!(OptionGreeks),
1507            NautilusDataType::InstrumentStatus => typed!(InstrumentStatus),
1508            NautilusDataType::InstrumentClose => typed!(InstrumentClose),
1509            NautilusDataType::Custom { type_name } => py
1510                .detach(|| {
1511                    self.inner.query_custom_data_dynamic(
1512                        &type_name,
1513                        identifiers.as_deref(),
1514                        start_nanos,
1515                        end_nanos,
1516                        where_clause,
1517                        files.clone(),
1518                        optimize_file_loading,
1519                    )
1520                })
1521                .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?,
1522            NautilusDataType::Instrument => {
1523                let instruments = py
1524                    .detach(|| {
1525                        self.inner.query_instruments_filtered_with_where(
1526                            identifiers.as_deref(),
1527                            start_nanos,
1528                            end_nanos,
1529                            where_clause,
1530                        )
1531                    })
1532                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1533
1534                return instruments
1535                    .into_iter()
1536                    .map(|instrument| instrument_any_to_pyobject(py, instrument))
1537                    .collect();
1538            }
1539            #[cfg(feature = "defi")]
1540            NautilusDataType::Defi => {
1541                return Err(to_pytype_err("Defi data is not supported by query"));
1542            }
1543            #[cfg(not(feature = "defi"))]
1544            #[allow(
1545                unreachable_patterns,
1546                reason = "DeFi variants can exist without this crate's defi feature"
1547            )]
1548            _ => return Err(to_pytype_err("Defi data is not supported by query")),
1549        };
1550
1551        let mut python_objects = Vec::new();
1552        for item in data {
1553            python_objects.push(data_to_pyobject(py, item)?);
1554        }
1555
1556        Ok(python_objects)
1557    }
1558
1559    /// Query quote tick data from Parquet files.
1560    ///
1561    /// # Parameters
1562    ///
1563    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1564    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1565    ///   For bars, partial matching is supported.
1566    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1567    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1568    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1569    ///
1570    /// # Returns
1571    ///
1572    /// Returns a vector of `QuoteTick` objects matching the query criteria.
1573    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1574    pub fn query_quote_ticks(
1575        &mut self,
1576        py: Python<'_>,
1577        identifiers: Option<Vec<String>>,
1578        start: Option<u64>,
1579        end: Option<u64>,
1580        where_clause: Option<&str>,
1581    ) -> PyResult<Vec<QuoteTick>> {
1582        query_parquet_data::<QuoteTick>(
1583            py,
1584            &mut self.inner,
1585            identifiers,
1586            start,
1587            end,
1588            where_clause,
1589            None,
1590            true,
1591            "Failed to query data",
1592        )
1593    }
1594
1595    /// Query trade tick data from Parquet files.
1596    ///
1597    /// # Parameters
1598    ///
1599    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1600    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1601    ///   For bars, partial matching is supported.
1602    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1603    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1604    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1605    ///
1606    /// # Returns
1607    ///
1608    /// Returns a vector of `TradeTick` objects matching the query criteria.
1609    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1610    pub fn query_trade_ticks(
1611        &mut self,
1612        py: Python<'_>,
1613        identifiers: Option<Vec<String>>,
1614        start: Option<u64>,
1615        end: Option<u64>,
1616        where_clause: Option<&str>,
1617    ) -> PyResult<Vec<TradeTick>> {
1618        query_parquet_data::<TradeTick>(
1619            py,
1620            &mut self.inner,
1621            identifiers,
1622            start,
1623            end,
1624            where_clause,
1625            None,
1626            true,
1627            "Failed to query data",
1628        )
1629    }
1630
1631    /// Query order book delta data from Parquet files.
1632    ///
1633    /// # Parameters
1634    ///
1635    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1636    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1637    ///   For bars, partial matching is supported.
1638    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1639    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1640    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1641    ///
1642    /// # Returns
1643    ///
1644    /// Returns a vector of `OrderBookDelta` objects matching the query criteria.
1645    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1646    pub fn query_order_book_deltas(
1647        &mut self,
1648        py: Python<'_>,
1649        identifiers: Option<Vec<String>>,
1650        start: Option<u64>,
1651        end: Option<u64>,
1652        where_clause: Option<&str>,
1653    ) -> PyResult<Vec<OrderBookDelta>> {
1654        query_parquet_data::<OrderBookDelta>(
1655            py,
1656            &mut self.inner,
1657            identifiers,
1658            start,
1659            end,
1660            where_clause,
1661            None,
1662            true,
1663            "Failed to query data",
1664        )
1665    }
1666
1667    /// Query bar data from Parquet files.
1668    ///
1669    /// # Parameters
1670    ///
1671    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1672    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1673    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match all bar types for that instrument).
1674    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1675    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1676    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1677    ///
1678    /// # Returns
1679    ///
1680    /// Returns a vector of Bar objects matching the query criteria.
1681    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1682    pub fn query_bars(
1683        &mut self,
1684        py: Python<'_>,
1685        identifiers: Option<Vec<String>>,
1686        start: Option<u64>,
1687        end: Option<u64>,
1688        where_clause: Option<&str>,
1689    ) -> PyResult<Vec<Bar>> {
1690        query_parquet_data::<Bar>(
1691            py,
1692            &mut self.inner,
1693            identifiers,
1694            start,
1695            end,
1696            where_clause,
1697            None,
1698            true,
1699            "Failed to query data",
1700        )
1701    }
1702
1703    /// Query order book depth data from Parquet files.
1704    ///
1705    /// # Parameters
1706    ///
1707    /// - `identifiers`: Optional list of identifiers to filter by.
1708    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1709    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1710    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1711    ///
1712    /// # Returns
1713    ///
1714    /// Returns a vector of `OrderBookDepth` objects matching the query criteria.
1715    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1716    pub fn query_order_book_depths(
1717        &mut self,
1718        py: Python<'_>,
1719        identifiers: Option<Vec<String>>,
1720        start: Option<u64>,
1721        end: Option<u64>,
1722        where_clause: Option<&str>,
1723    ) -> PyResult<Vec<OrderBookDepth>> {
1724        query_parquet_data::<OrderBookDepth>(
1725            py,
1726            &mut self.inner,
1727            identifiers,
1728            start,
1729            end,
1730            where_clause,
1731            None,
1732            true,
1733            "Failed to query data",
1734        )
1735    }
1736
1737    /// Query mark price update data from Parquet files.
1738    ///
1739    /// # Parameters
1740    ///
1741    /// - `identifiers`: Optional list of identifiers to filter by.
1742    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1743    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1744    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1745    ///
1746    /// # Returns
1747    ///
1748    /// Returns a vector of `MarkPriceUpdate` objects matching the query criteria.
1749    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1750    pub fn query_mark_price_updates(
1751        &mut self,
1752        py: Python<'_>,
1753        identifiers: Option<Vec<String>>,
1754        start: Option<u64>,
1755        end: Option<u64>,
1756        where_clause: Option<&str>,
1757    ) -> PyResult<Vec<MarkPriceUpdate>> {
1758        query_parquet_data::<MarkPriceUpdate>(
1759            py,
1760            &mut self.inner,
1761            identifiers,
1762            start,
1763            end,
1764            where_clause,
1765            None,
1766            true,
1767            "Failed to query data",
1768        )
1769    }
1770
1771    /// Query index price update data from Parquet files.
1772    ///
1773    /// # Parameters
1774    ///
1775    /// - `identifiers`: Optional list of identifiers to filter by.
1776    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1777    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1778    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1779    ///
1780    /// # Returns
1781    ///
1782    /// Returns a vector of `IndexPriceUpdate` objects matching the query criteria.
1783    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1784    pub fn query_index_price_updates(
1785        &mut self,
1786        py: Python<'_>,
1787        identifiers: Option<Vec<String>>,
1788        start: Option<u64>,
1789        end: Option<u64>,
1790        where_clause: Option<&str>,
1791    ) -> PyResult<Vec<IndexPriceUpdate>> {
1792        query_parquet_data::<IndexPriceUpdate>(
1793            py,
1794            &mut self.inner,
1795            identifiers,
1796            start,
1797            end,
1798            where_clause,
1799            None,
1800            true,
1801            "Failed to query data",
1802        )
1803    }
1804
1805    /// Query option greeks data from Parquet files.
1806    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1807    pub fn query_option_greeks(
1808        &mut self,
1809        py: Python<'_>,
1810        identifiers: Option<Vec<String>>,
1811        start: Option<u64>,
1812        end: Option<u64>,
1813        where_clause: Option<&str>,
1814    ) -> PyResult<Vec<OptionGreeks>> {
1815        query_parquet_data::<OptionGreeks>(
1816            py,
1817            &mut self.inner,
1818            identifiers,
1819            start,
1820            end,
1821            where_clause,
1822            None,
1823            true,
1824            "Failed to query data",
1825        )
1826    }
1827
1828    /// List all data types available in the catalog.
1829    ///
1830    /// # Returns
1831    ///
1832    /// Returns the data, record, and instrument types stored in the catalog.
1833    pub fn list_data_types(&self, py: Python<'_>) -> PyResult<Vec<PyCatalogDataType>> {
1834        py.detach(|| self.inner.list_data_types())
1835            .map(|data_types| data_types.into_iter().map(PyCatalogDataType::new).collect())
1836            .map_err(|e| PyIOError::new_err(format!("Failed to list data types: {e}")))
1837    }
1838
1839    /// List all live run IDs available in the catalog.
1840    ///
1841    /// # Returns
1842    ///
1843    /// Returns a list of live run IDs (as directory stems) in the catalog.
1844    pub fn list_live_runs(&self, py: Python<'_>) -> PyResult<Vec<String>> {
1845        py.detach(|| self.inner.list_live_runs())
1846            .map_err(|e| PyIOError::new_err(format!("Failed to list live runs: {e}")))
1847    }
1848
1849    /// List all backtest run IDs available in the catalog.
1850    ///
1851    /// # Returns
1852    ///
1853    /// Returns a list of backtest run IDs (as directory stems) in the catalog.
1854    pub fn list_backtest_runs(&self, py: Python<'_>) -> PyResult<Vec<String>> {
1855        py.detach(|| self.inner.list_backtest_runs())
1856            .map_err(|e| PyIOError::new_err(format!("Failed to list backtest runs: {e}")))
1857    }
1858
1859    /// List all backtest run instances available in the catalog.
1860    pub fn list_backtests(&self, py: Python<'_>) -> PyResult<Vec<String>> {
1861        py.detach(|| self.inner.list_backtest_runs())
1862            .map_err(|e| PyIOError::new_err(format!("Failed to list backtests: {e}")))
1863    }
1864
1865    /// Read data from a live run instance.
1866    ///
1867    /// # Parameters
1868    ///
1869    /// - `instance_id`: The ID of the live run instance
1870    ///
1871    /// # Returns
1872    ///
1873    /// Returns a list of data objects from the live run, sorted by timestamp.
1874    #[pyo3(signature = (instance_id))]
1875    pub fn read_live_run(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1876        let data = self
1877            .inner
1878            .read_live_run(instance_id)
1879            .map_err(|e| PyIOError::new_err(format!("Failed to read live run: {e}")))?;
1880
1881        let mut python_objects = Vec::new();
1882        for item in data {
1883            python_objects.push(data_to_pyobject(py, item)?);
1884        }
1885
1886        Ok(python_objects)
1887    }
1888
1889    /// Read data from a backtest run instance.
1890    ///
1891    /// # Parameters
1892    ///
1893    /// - `instance_id`: The ID of the backtest run instance
1894    ///
1895    /// # Returns
1896    ///
1897    /// Returns a list of data objects from the backtest run, sorted by timestamp.
1898    #[pyo3(signature = (instance_id))]
1899    pub fn read_backtest(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1900        let data = self
1901            .inner
1902            .read_backtest(instance_id)
1903            .map_err(|e| PyIOError::new_err(format!("Failed to read backtest: {e}")))?;
1904
1905        let mut python_objects = Vec::new();
1906        for item in data {
1907            python_objects.push(data_to_pyobject(py, item)?);
1908        }
1909
1910        Ok(python_objects)
1911    }
1912
1913    /// Convert stream data from feather files to parquet files.
1914    ///
1915    /// This method reads data from feather files generated during a backtest or live run
1916    /// and writes it to the catalog in parquet format. It's useful for converting temporary
1917    /// stream data into a more permanent and queryable format.
1918    ///
1919    /// # Parameters
1920    ///
1921    /// - `instance_id`: The ID of the backtest or live run instance
1922    /// - `data_type`: The stored family to convert (data type or record type).
1923    /// - `environment`: The environment of the run, which names the folder holding its feather files (default: `Environment.BACKTEST`)
1924    /// - `identifiers`: Optional list of identifiers to filter by (instrument IDs or bar types)
1925    /// - `use_ts_event_for_ts_init`: If true, replaces the `ts_init` column with `ts_event` column values before deserializing
1926    ///
1927    /// # Returns
1928    ///
1929    /// Returns nothing on success.
1930    ///
1931    /// # Examples
1932    ///
1933    /// ```python
1934    /// # Convert backtest stream data to parquet
1935    /// catalog.convert_stream_to_data(
1936    ///     "instance-123",
1937    ///     NautilusDataType.QuoteTick,
1938    ///     environment=Environment.BACKTEST
1939    /// )
1940    ///
1941    /// # Convert live run data with identifier filtering
1942    /// catalog.convert_stream_to_data(
1943    ///     "instance-456",
1944    ///     NautilusDataType.TradeTick,
1945    ///     environment=Environment.LIVE,
1946    ///     identifiers=["EUR/USD.SIM"]
1947    /// )
1948    /// ```
1949    #[pyo3(signature = (instance_id, data_type, environment=Environment::Backtest, identifiers=None, use_ts_event_for_ts_init=false))]
1950    #[expect(clippy::needless_pass_by_value)]
1951    pub fn convert_stream_to_data(
1952        &mut self,
1953        py: Python<'_>,
1954        instance_id: &str,
1955        data_type: PyCatalogDataType,
1956        environment: Environment,
1957        identifiers: Option<Vec<String>>,
1958        use_ts_event_for_ts_init: bool,
1959    ) -> PyResult<()> {
1960        let data_type = data_type.into_inner();
1961
1962        py.detach(|| {
1963            self.inner.convert_stream_to_data(
1964                instance_id,
1965                &data_type,
1966                environment,
1967                identifiers.as_deref(),
1968                use_ts_event_for_ts_init,
1969            )
1970        })
1971        .map_err(|e| PyIOError::new_err(format!("Failed to convert stream to data: {e}")))
1972    }
1973
1974    /// Query custom data from Parquet files.
1975    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None))]
1976    #[expect(clippy::needless_pass_by_value)]
1977    pub fn query_custom_data(
1978        &mut self,
1979        py: Python<'_>,
1980        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1981            '_,
1982            PyAny,
1983        >,
1984        identifiers: Option<Vec<String>>,
1985        start: Option<u64>,
1986        end: Option<u64>,
1987        where_clause: Option<&str>,
1988    ) -> PyResult<Vec<Py<PyAny>>> {
1989        let NautilusDataType::Custom { type_name } = nautilus_data_type_from_py(data_type)? else {
1990            return Err(to_pytype_err("data_type must be a custom NautilusDataType"));
1991        };
1992
1993        let start_nanos = start.map(UnixNanos::from);
1994        let end_nanos = end.map(UnixNanos::from);
1995
1996        let data = py
1997            .detach(|| {
1998                self.inner.query_custom_data_dynamic(
1999                    &type_name,
2000                    identifiers.as_deref(),
2001                    start_nanos,
2002                    end_nanos,
2003                    where_clause,
2004                    None,
2005                    true,
2006                )
2007            })
2008            .map_err(|e| PyIOError::new_err(format!("Failed to query custom data: {e}")))?;
2009
2010        let mut python_objects = Vec::new();
2011
2012        for item in data {
2013            let py_obj: Py<PyAny> = match item {
2014                Data::Custom(custom) => Py::new(py, custom)?.into_any(),
2015                _ => return Err(PyIOError::new_err("Expected custom data")),
2016            };
2017
2018            python_objects.push(py_obj);
2019        }
2020
2021        Ok(python_objects)
2022    }
2023}
2024
2025impl PyParquetDataCatalog {
2026    fn mutate<T: Send>(
2027        &self,
2028        py: Python<'_>,
2029        operation: impl FnOnce() -> anyhow::Result<T> + Send,
2030    ) -> anyhow::Result<T> {
2031        py.detach(|| {
2032            let _guard = self
2033                .mutation_lock
2034                .lock()
2035                .map_err(|_| anyhow::anyhow!("catalog mutation lock is poisoned"))?;
2036            operation()
2037        })
2038    }
2039}
2040
2041// Codes follow the Parquet format codec numbering, except that 5 writes LZ4_RAW rather than the
2042// deprecated Hadoop-framed LZ4; LZO (3) is excluded because the writer cannot produce it
2043fn catalog_compression_from_code(code: u8) -> PyResult<CatalogCompression> {
2044    match code {
2045        0 => Ok(CatalogCompression::Uncompressed),
2046        1 => Ok(CatalogCompression::Snappy),
2047        2 => Ok(CatalogCompression::Gzip),
2048        4 => Ok(CatalogCompression::Brotli),
2049        5 => Ok(CatalogCompression::Lz4),
2050        6 => Ok(CatalogCompression::Zstd),
2051        _ => Err(to_pyvalue_err(format!(
2052            "Invalid compression code {code}, expected one of 0 (UNCOMPRESSED), 1 (SNAPPY), \
2053             2 (GZIP), 4 (BROTLI), 5 (LZ4_RAW), or 6 (ZSTD)"
2054        ))),
2055    }
2056}
2057
2058#[cfg(test)]
2059mod tests {
2060    use std::{
2061        borrow::Borrow,
2062        cell::Cell,
2063        sync::{Arc, Barrier, Once, TryLockError, mpsc},
2064        thread,
2065        time::Duration,
2066    };
2067
2068    use arrow::{datatypes::Schema, error::ArrowError, record_batch::RecordBatch};
2069    use nautilus_model::{
2070        custom_data,
2071        data::{
2072            BookOrder, CustomData, CustomDataTrait, DataType, ensure_custom_data_json_registered,
2073            register_arrow,
2074        },
2075        enums::{BookAction, OrderSide},
2076        identifiers::InstrumentId,
2077        python::data::PyNautilusDataType,
2078        types::{Price, Quantity},
2079    };
2080    use nautilus_serialization::{
2081        arrow::{ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodingError},
2082        arrow_custom_data,
2083    };
2084    use rstest::rstest;
2085    use tempfile::TempDir;
2086
2087    use super::*;
2088
2089    #[rstest]
2090    #[case::uncompressed(0, CatalogCompression::Uncompressed)]
2091    #[case::snappy(1, CatalogCompression::Snappy)]
2092    #[case::gzip(2, CatalogCompression::Gzip)]
2093    #[case::brotli(4, CatalogCompression::Brotli)]
2094    #[case::lz4(5, CatalogCompression::Lz4)]
2095    #[case::zstd(6, CatalogCompression::Zstd)]
2096    fn catalog_compression_from_code_maps_parquet_codes(
2097        #[case] code: u8,
2098        #[case] expected: CatalogCompression,
2099    ) {
2100        assert_eq!(catalog_compression_from_code(code).unwrap(), expected);
2101    }
2102
2103    #[rstest]
2104    fn test_write_parquet_data_releases_gil() {
2105        Python::initialize();
2106        let directory = TempDir::new().unwrap();
2107        let mut catalog = PyParquetDataCatalog::py_new(
2108            directory.path().to_str().unwrap(),
2109            None,
2110            None,
2111            None,
2112            None,
2113        )
2114        .unwrap();
2115        let delta = OrderBookDelta::clear(
2116            InstrumentId::from("ETH/USDT.BINANCE"),
2117            17,
2118            23.into(),
2119            29.into(),
2120        );
2121
2122        let path = Python::attach(|py| {
2123            write_parquet_data(
2124                py,
2125                &catalog,
2126                vec![GilCheckedDelta(delta, Some(&catalog.mutation_lock))],
2127                None,
2128                None,
2129                false,
2130                "deltas",
2131            )
2132            .unwrap()
2133        });
2134
2135        let loaded = catalog
2136            .inner
2137            .query::<OrderBookDelta>(None, None, None, None, None, true)
2138            .unwrap();
2139
2140        assert!(catalog.inner.file_exists(&path).unwrap());
2141        assert_eq!(loaded, vec![delta]);
2142    }
2143
2144    #[rstest]
2145    fn test_query_parquet_data_releases_gil() {
2146        Python::initialize();
2147        let directory = TempDir::new().unwrap();
2148        let mut catalog = ParquetDataCatalog::new(directory.path(), None, None, None, None);
2149        let delta = OrderBookDelta::clear(
2150            InstrumentId::from("ETH/USDT.BINANCE"),
2151            17,
2152            23.into(),
2153            29.into(),
2154        );
2155        catalog
2156            .write_to_parquet(&[delta], None, None, None)
2157            .unwrap();
2158
2159        let loaded = Python::attach(|py| {
2160            query_parquet_data::<GilCheckedDelta>(
2161                py,
2162                &mut catalog,
2163                None,
2164                None,
2165                None,
2166                None,
2167                None,
2168                true,
2169                "Query failed",
2170            )
2171            .unwrap()
2172        });
2173
2174        assert_eq!(loaded.len(), 1);
2175        assert_eq!(loaded[0].0, delta);
2176        assert!(loaded[0].1.is_none());
2177        assert_eq!(loaded[0].0.action, BookAction::Clear);
2178    }
2179
2180    #[rstest]
2181    fn test_catalog_python_delta_round_trip_and_maintenance() {
2182        Python::initialize();
2183        let directory = TempDir::new().unwrap();
2184        let instrument_id = InstrumentId::from("ETH/USDT.BINANCE");
2185        let deltas = vec![
2186            OrderBookDelta::new(
2187                instrument_id,
2188                BookAction::Add,
2189                BookOrder::new(
2190                    OrderSide::Buy,
2191                    Price::from("100.23"),
2192                    Quantity::from("1.234"),
2193                    41,
2194                ),
2195                7,
2196                17,
2197                23.into(),
2198                29.into(),
2199            ),
2200            OrderBookDelta::new(
2201                instrument_id,
2202                BookAction::Update,
2203                BookOrder::new(
2204                    OrderSide::Sell,
2205                    Price::from("101.45"),
2206                    Quantity::from("2.345"),
2207                    47,
2208                ),
2209                8,
2210                31,
2211                37.into(),
2212                43.into(),
2213            ),
2214        ];
2215
2216        Python::attach(|py| {
2217            let catalog = PyParquetDataCatalog::py_new(
2218                directory.path().to_str().unwrap(),
2219                None,
2220                None,
2221                None,
2222                None,
2223            )
2224            .unwrap();
2225            let catalog = Bound::new(py, catalog).unwrap();
2226            let data_type = PyNautilusDataType::new(NautilusDataType::OrderBookDelta);
2227            let data_type = Bound::new(py, data_type).unwrap();
2228            let path: String = catalog
2229                .call_method1("write_order_book_deltas", (deltas.clone(), 11, 53))
2230                .unwrap()
2231                .extract()
2232                .unwrap();
2233            let loaded: Vec<OrderBookDelta> = catalog
2234                .call_method0("query_order_book_deltas")
2235                .unwrap()
2236                .extract()
2237                .unwrap();
2238            let queried: Vec<OrderBookDelta> = catalog
2239                .call_method1("query", (&data_type,))
2240                .unwrap()
2241                .extract()
2242                .unwrap();
2243            let intervals: Vec<(u64, u64)> = catalog
2244                .call_method1("get_intervals", (&data_type, instrument_id.to_string()))
2245                .unwrap()
2246                .extract()
2247                .unwrap();
2248
2249            assert!(catalog.borrow().inner.file_exists(&path).unwrap());
2250            assert_eq!(loaded, deltas);
2251            assert_eq!(queried, deltas);
2252            assert_eq!(intervals, vec![(11, 53)]);
2253
2254            catalog
2255                .call_method1("consolidate_data", (&data_type, instrument_id.to_string()))
2256                .unwrap();
2257            catalog
2258                .call_method1(
2259                    "delete_data_range",
2260                    (&data_type, instrument_id.to_string(), 29, 29),
2261                )
2262                .unwrap();
2263            let remaining: Vec<OrderBookDelta> = catalog
2264                .call_method0("query_order_book_deltas")
2265                .unwrap()
2266                .extract()
2267                .unwrap();
2268
2269            assert_eq!(remaining, vec![deltas[1]]);
2270        });
2271    }
2272
2273    #[rstest]
2274    fn test_catalog_python_delta_write_errors_and_empty_input() {
2275        Python::initialize();
2276        let directory = TempDir::new().unwrap();
2277
2278        Python::attach(|py| {
2279            let catalog = PyParquetDataCatalog::py_new(
2280                directory.path().to_str().unwrap(),
2281                None,
2282                None,
2283                None,
2284                None,
2285            )
2286            .unwrap();
2287            let catalog = Bound::new(py, catalog).unwrap();
2288            let path: String = catalog
2289                .call_method1("write_order_book_deltas", (Vec::<OrderBookDelta>::new(),))
2290                .unwrap()
2291                .extract()
2292                .unwrap();
2293            let delta = OrderBookDelta::clear(
2294                InstrumentId::from("ETH/USDT.BINANCE"),
2295                17,
2296                23.into(),
2297                29.into(),
2298            );
2299
2300            let earlier = OrderBookDelta {
2301                ts_init: 19.into(),
2302                ..delta
2303            };
2304
2305            let error = catalog
2306                .call_method1("write_order_book_deltas", (vec![delta, earlier],))
2307                .unwrap_err();
2308            let loaded: Vec<OrderBookDelta> = catalog
2309                .call_method0("query_order_book_deltas")
2310                .unwrap()
2311                .extract()
2312                .unwrap();
2313
2314            assert_eq!(path, "");
2315            assert!(error.is_instance_of::<PyIOError>(py));
2316            assert_eq!(
2317                error.value(py).str().unwrap().to_str().unwrap(),
2318                "Failed to write order book deltas: nautilus_model_data_delta_order_book_delta timestamps must be in ascending order"
2319            );
2320            assert_eq!(loaded, Vec::<OrderBookDelta>::new());
2321        });
2322    }
2323
2324    #[rstest]
2325    fn test_concurrent_delta_writes_reject_overlapping_intervals() {
2326        Python::initialize();
2327        let directory = TempDir::new().unwrap();
2328        let mut catalog = PyParquetDataCatalog::py_new(
2329            directory.path().to_str().unwrap(),
2330            None,
2331            None,
2332            None,
2333            None,
2334        )
2335        .unwrap();
2336        let instrument_id = InstrumentId::from("ETH/USDT.BINANCE");
2337        let first = vec![
2338            OrderBookDelta::clear(instrument_id, 11, 17.into(), 23.into()),
2339            OrderBookDelta::clear(instrument_id, 29, 31.into(), 37.into()),
2340        ];
2341        let second = vec![
2342            OrderBookDelta::clear(instrument_id, 41, 43.into(), 37.into()),
2343            OrderBookDelta::clear(instrument_id, 47, 53.into(), 59.into()),
2344        ];
2345        let start = Barrier::new(2);
2346
2347        let (first_result, second_result) = thread::scope(|scope| {
2348            let first_thread = scope.spawn(|| {
2349                start.wait();
2350                Python::attach(|py| {
2351                    catalog.write_order_book_deltas(py, first.clone(), None, None, false)
2352                })
2353            });
2354
2355            let second_thread = scope.spawn(|| {
2356                start.wait();
2357                Python::attach(|py| {
2358                    catalog.write_order_book_deltas(py, second.clone(), None, None, false)
2359                })
2360            });
2361
2362            (first_thread.join().unwrap(), second_thread.join().unwrap())
2363        });
2364
2365        let (expected, error) = match (first_result, second_result) {
2366            (Ok(_), Err(e)) => (first, e),
2367            (Err(e), Ok(_)) => (second, e),
2368            results => {
2369                panic!("Expected one successful write and one overlap error, received {results:?}")
2370            }
2371        };
2372
2373        let loaded = catalog
2374            .inner
2375            .query::<OrderBookDelta>(None, None, None, None, None, true)
2376            .unwrap();
2377
2378        Python::attach(|py| {
2379            assert!(error.is_instance_of::<PyIOError>(py));
2380            assert!(
2381                error
2382                    .value(py)
2383                    .str()
2384                    .unwrap()
2385                    .to_str()
2386                    .unwrap()
2387                    .contains("would create non-disjoint intervals")
2388            );
2389        });
2390
2391        assert_eq!(loaded, expected);
2392    }
2393
2394    #[rstest]
2395    fn test_poisoned_mutation_lock_returns_python_io_error() {
2396        Python::initialize();
2397        let directory = TempDir::new().unwrap();
2398        let catalog = PyParquetDataCatalog::py_new(
2399            directory.path().to_str().unwrap(),
2400            None,
2401            None,
2402            None,
2403            None,
2404        )
2405        .unwrap();
2406
2407        let poisoned = thread::scope(|scope| {
2408            scope
2409                .spawn(|| {
2410                    let _guard = catalog.mutation_lock.lock().unwrap();
2411                    panic!("native mutation failed");
2412                })
2413                .join()
2414                .is_err()
2415        });
2416
2417        let error = Python::attach(|py| {
2418            catalog
2419                .write_order_book_deltas(py, Vec::new(), None, None, false)
2420                .unwrap_err()
2421        });
2422
2423        assert!(poisoned);
2424        Python::attach(|py| {
2425            assert!(error.is_instance_of::<PyIOError>(py));
2426            assert_eq!(
2427                error.value(py).str().unwrap().to_str().unwrap(),
2428                "Failed to write order book deltas: catalog mutation lock is poisoned"
2429            );
2430        });
2431    }
2432
2433    #[rstest]
2434    fn test_mutation_wait_releases_gil() {
2435        Python::initialize();
2436        let directory = TempDir::new().unwrap();
2437        let catalog = PyParquetDataCatalog::py_new(
2438            directory.path().to_str().unwrap(),
2439            None,
2440            None,
2441            None,
2442            None,
2443        )
2444        .unwrap();
2445        let delta = OrderBookDelta::clear(
2446            InstrumentId::from("ETH/USDT.BINANCE"),
2447            17,
2448            23.into(),
2449            29.into(),
2450        );
2451        let (started_tx, started_rx) = mpsc::channel();
2452        let (available_tx, available_rx) = mpsc::channel();
2453        let guard = catalog.mutation_lock.lock().unwrap();
2454
2455        let (gil_available, path) = thread::scope(|scope| {
2456            let writer = scope.spawn(|| {
2457                Python::attach(|py| {
2458                    started_tx.send(()).unwrap();
2459                    catalog.write_order_book_deltas(py, vec![delta], None, None, false)
2460                })
2461            });
2462
2463            started_rx.recv().unwrap();
2464
2465            let probe = scope.spawn(|| Python::attach(|_| available_tx.send(()).unwrap()));
2466            let gil_available = available_rx.recv_timeout(Duration::from_secs(5)).is_ok();
2467            drop(guard);
2468            let path = writer.join().unwrap().unwrap();
2469            probe.join().unwrap();
2470            (gil_available, path)
2471        });
2472
2473        assert!(
2474            gil_available,
2475            "GIL remained held while waiting for the mutation lock"
2476        );
2477        assert!(catalog.inner.file_exists(&path).unwrap());
2478    }
2479
2480    #[rstest]
2481    #[case::delete_data("delete_data_range")]
2482    #[case::delete_catalog("delete_catalog_range")]
2483    #[case::consolidate_data("consolidate_data_by_period")]
2484    #[case::consolidate_catalog("consolidate_catalog_by_period")]
2485    fn test_custom_maintenance_keeps_callbacks_attached(#[case] method: &str) {
2486        Python::initialize();
2487        register_gil_checked_custom_data();
2488        let directory = TempDir::new().unwrap();
2489
2490        let expected = [11u64, 29, 47]
2491            .into_iter()
2492            .map(|ts| {
2493                let value = GilCheckedCustomData {
2494                    value: ts + 100,
2495                    ts_event: (ts - 3).into(),
2496                    ts_init: ts.into(),
2497                };
2498
2499                let data_type = DataType::new(GilCheckedCustomData::type_name_static(), None, None);
2500                CustomData::new(Arc::new(value), data_type)
2501            })
2502            .collect::<Vec<_>>();
2503
2504        Python::attach(|py| {
2505            let catalog = PyParquetDataCatalog::py_new(
2506                directory.path().to_str().unwrap(),
2507                None,
2508                None,
2509                None,
2510                None,
2511            )
2512            .unwrap();
2513            catalog
2514                .inner
2515                .write_custom_data_batch(&expected, None, None, None)
2516                .unwrap();
2517            let catalog = Bound::new(py, catalog).unwrap();
2518
2519            let data_type = PyNautilusDataType::new(NautilusDataType::Custom {
2520                type_name: GilCheckedCustomData::type_name_static().to_string(),
2521            });
2522
2523            let data_type = Bound::new(py, data_type).unwrap();
2524            CALLBACKS.with(|calls| calls.set((false, false)));
2525
2526            match method {
2527                "delete_data_range" => {
2528                    catalog.call_method1(method, (&data_type, None::<String>, 29, 29))
2529                }
2530                "delete_catalog_range" => catalog.call_method1(method, (29, 29)),
2531                "consolidate_data_by_period" => {
2532                    catalog.call_method1(method, (&data_type, None::<String>, 20))
2533                }
2534                "consolidate_catalog_by_period" => catalog.call_method1(method, (20,)),
2535                _ => unreachable!(),
2536            }
2537            .unwrap();
2538
2539            let callbacks = CALLBACKS.with(Cell::get);
2540            let loaded = catalog
2541                .borrow_mut()
2542                .inner
2543                .query_custom_data_dynamic(
2544                    GilCheckedCustomData::type_name_static(),
2545                    None,
2546                    None,
2547                    None,
2548                    None,
2549                    None,
2550                    false,
2551                )
2552                .unwrap();
2553            let expected = expected
2554                .iter()
2555                .filter(|value| !method.starts_with("delete") || value.ts_init().as_u64() != 29)
2556                .cloned()
2557                .map(Data::Custom)
2558                .collect::<Vec<_>>();
2559
2560            assert_eq!(callbacks, (true, true));
2561            assert_eq!(loaded, expected);
2562        });
2563    }
2564
2565    #[rstest]
2566    #[case::empty(true)]
2567    #[case::nonempty(false)]
2568    fn test_custom_write_prepares_attached_before_native_mutation(#[case] empty: bool) {
2569        Python::initialize();
2570        register_gil_checked_custom_data();
2571        let directory = TempDir::new().unwrap();
2572
2573        Python::attach(|py| {
2574            let catalog = PyParquetDataCatalog::py_new(
2575                directory.path().to_str().unwrap(),
2576                None,
2577                None,
2578                None,
2579                None,
2580            )
2581            .unwrap();
2582            let catalog = Bound::new(py, catalog).unwrap();
2583
2584            let rows = if empty {
2585                Vec::new()
2586            } else {
2587                let value = GilCheckedCustomData {
2588                    value: 113,
2589                    ts_event: 17.into(),
2590                    ts_init: 23.into(),
2591                };
2592
2593                let data_type = DataType::new(GilCheckedCustomData::type_name_static(), None, None);
2594                vec![CustomData::new(Arc::new(value), data_type)]
2595            };
2596
2597            let objects = rows
2598                .iter()
2599                .cloned()
2600                .map(|row| Py::new(py, row).unwrap())
2601                .collect::<Vec<_>>();
2602            CALLBACKS.with(|calls| calls.set((false, false)));
2603            let path: String = catalog
2604                .call_method1("write_custom_data", (objects,))
2605                .unwrap()
2606                .extract()
2607                .unwrap();
2608            let callbacks = CALLBACKS.with(Cell::get);
2609            let loaded = catalog
2610                .borrow_mut()
2611                .inner
2612                .query_custom_data_dynamic(
2613                    GilCheckedCustomData::type_name_static(),
2614                    None,
2615                    None,
2616                    None,
2617                    None,
2618                    None,
2619                    false,
2620                )
2621                .unwrap();
2622
2623            assert_eq!(callbacks, (!empty, false));
2624            assert_eq!(
2625                loaded,
2626                rows.into_iter().map(Data::Custom).collect::<Vec<_>>()
2627            );
2628
2629            if empty {
2630                assert_eq!(path, "");
2631            } else {
2632                assert!(catalog.borrow().inner.file_exists(&path).unwrap());
2633            }
2634        });
2635    }
2636
2637    #[arrow_custom_data]
2638    #[custom_data]
2639    struct GilCheckedCustomData {
2640        value: u64,
2641        ts_event: UnixNanos,
2642        ts_init: UnixNanos,
2643    }
2644
2645    thread_local! {
2646        static CALLBACKS: Cell<(bool, bool)> = const { Cell::new((false, false)) };
2647    }
2648
2649    fn register_gil_checked_custom_data() {
2650        static REGISTER: Once = Once::new();
2651        REGISTER.call_once(|| {
2652            ensure_custom_data_json_registered::<GilCheckedCustomData>().unwrap();
2653            register_arrow(
2654                GilCheckedCustomData::type_name_static(),
2655                Arc::new(GilCheckedCustomData::get_schema(None)),
2656                Box::new(|items| {
2657                    assert_gil_held();
2658                    CALLBACKS.with(|calls| calls.set((true, calls.get().1)));
2659                    Python::attach(|_| {
2660                        let rows = items
2661                            .iter()
2662                            .map(|item| {
2663                                item.as_any()
2664                                    .downcast_ref::<GilCheckedCustomData>()
2665                                    .unwrap()
2666                                    .clone()
2667                            })
2668                            .collect::<Vec<_>>();
2669
2670                        let metadata = rows[0].metadata();
2671                        GilCheckedCustomData::encode_batch(&metadata, &rows)
2672                            .map_err(anyhow::Error::from)
2673                    })
2674                }),
2675                Box::new(|metadata, batch| {
2676                    assert_gil_held();
2677                    CALLBACKS.with(|calls| calls.set((calls.get().0, true)));
2678                    Python::attach(|_| GilCheckedCustomData::decode_data_batch(metadata, batch))
2679                        .map_err(anyhow::Error::from)
2680                }),
2681            )
2682            .unwrap();
2683        });
2684    }
2685
2686    #[derive(Clone, Copy, Debug)]
2687    struct GilCheckedDelta<'a>(OrderBookDelta, Option<&'a Mutex<()>>);
2688
2689    impl HasTsInit for GilCheckedDelta<'_> {
2690        fn ts_init(&self) -> UnixNanos {
2691            self.0.ts_init
2692        }
2693    }
2694
2695    impl HasCatalogDataType for GilCheckedDelta<'_> {
2696        fn catalog_data_type() -> NautilusDataType {
2697            NautilusDataType::OrderBookDelta
2698        }
2699    }
2700
2701    impl ArrowSchemaProvider for GilCheckedDelta<'_> {
2702        fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema {
2703            OrderBookDelta::get_schema(metadata)
2704        }
2705    }
2706
2707    impl EncodeToRecordBatch for GilCheckedDelta<'_> {
2708        fn encode_batch<T>(
2709            metadata: &HashMap<String, String>,
2710            data: &[T],
2711        ) -> Result<RecordBatch, ArrowError>
2712        where
2713            T: Borrow<Self>,
2714        {
2715            assert_gil_released();
2716            let deltas = data.iter().map(|item| item.borrow().0).collect::<Vec<_>>();
2717            OrderBookDelta::encode_batch(metadata, &deltas)
2718        }
2719
2720        fn metadata(&self) -> HashMap<String, String> {
2721            if let Some(lock) = self.1 {
2722                assert!(matches!(lock.try_lock(), Err(TryLockError::WouldBlock)));
2723            }
2724
2725            self.0.metadata()
2726        }
2727    }
2728
2729    impl DecodeTypedFromRecordBatch for GilCheckedDelta<'_> {
2730        fn decode_typed_batch(
2731            metadata: &HashMap<String, String>,
2732            record_batch: RecordBatch,
2733        ) -> Result<Vec<Self>, EncodingError> {
2734            assert_gil_released();
2735            OrderBookDelta::decode_typed_batch(metadata, record_batch)
2736                .map(|deltas| deltas.into_iter().map(|delta| Self(delta, None)).collect())
2737        }
2738    }
2739
2740    fn assert_gil_released() {
2741        // SAFETY: Python is initialized before this check, which does not require the GIL.
2742        assert_eq!(unsafe { pyo3::ffi::PyGILState_Check() }, 0);
2743    }
2744
2745    fn assert_gil_held() {
2746        // SAFETY: Python is initialized before this check, which does not require the GIL.
2747        assert_eq!(unsafe { pyo3::ffi::PyGILState_Check() }, 1);
2748    }
2749}