1#![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#[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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
2041fn 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 assert_eq!(unsafe { pyo3::ffi::PyGILState_Check() }, 0);
2743 }
2744
2745 fn assert_gil_held() {
2746 assert_eq!(unsafe { pyo3::ffi::PyGILState_Check() }, 1);
2748 }
2749}