Skip to main content

nautilus_model/python/data/
mod.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//! Data types for the trading domain model.
17
18use std::{
19    collections::hash_map::DefaultHasher,
20    hash::{Hash, Hasher},
21};
22
23pub mod bar;
24pub mod bet;
25pub mod close;
26pub mod data_type;
27pub mod delta;
28pub mod deltas;
29pub mod depth;
30pub mod funding;
31pub mod greeks;
32pub mod option_chain;
33pub mod order;
34pub mod prices;
35pub mod quote;
36pub mod status;
37pub mod trade;
38
39#[cfg(feature = "python")]
40pub mod custom;
41
42#[cfg(feature = "python")]
43use nautilus_core::python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err};
44use pyo3::prelude::*;
45
46use crate::{
47    data::{
48        Bar, CustomData, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus,
49        MarkPriceUpdate, NautilusDataType, NautilusRecordType, OptionGreeks, OrderBookDelta,
50        QuoteTick, TradeTick, close::InstrumentClose, is_monotonically_increasing_by_init,
51        register_python_data_class,
52    },
53    python::instruments::instrument_any_to_pyobject,
54};
55
56const ERROR_MONOTONICITY: &str = "`data` was not monotonically increasing by the `ts_init` field";
57
58#[derive(Clone, Debug, Eq, Hash, PartialEq)]
59#[pyclass(frozen, name = "NautilusDataType", module = "nautilus_trader.model")]
60#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")]
61pub struct PyNautilusDataType {
62    inner: NautilusDataType,
63}
64
65impl PyNautilusDataType {
66    #[must_use]
67    pub const fn new(inner: NautilusDataType) -> Self {
68        Self { inner }
69    }
70
71    #[must_use]
72    pub fn into_inner(self) -> NautilusDataType {
73        self.inner
74    }
75
76    #[must_use]
77    pub fn inner(&self) -> NautilusDataType {
78        self.inner.clone()
79    }
80}
81
82macro_rules! define_nautilus_data_type_class_attrs {
83    (
84        $(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?
85    ) => {
86        #[pyo3_stub_gen::derive::gen_stub_pymethods]
87        #[pymethods]
88        impl PyNautilusDataType {
89            $(
90                #[classattr]
91                #[expect(
92                    non_snake_case,
93                    reason = "Python selector attributes match Rust data type variant names"
94                )]
95                fn $variant() -> PyNautilusDataType {
96                    Self::new(NautilusDataType::$variant)
97                }
98            )+
99        }
100    };
101}
102
103crate::for_each_data_type!(define_nautilus_data_type_class_attrs);
104
105#[pyo3_stub_gen::derive::gen_stub_pymethods]
106#[pymethods]
107impl PyNautilusDataType {
108    #[cfg(feature = "defi")]
109    #[classattr]
110    #[expect(
111        clippy::use_self,
112        reason = "PyO3 stub generation needs the concrete return type"
113    )]
114    #[expect(
115        non_snake_case,
116        reason = "Python selector attributes match Rust data type variant names"
117    )]
118    fn Defi() -> PyNautilusDataType {
119        Self::new(NautilusDataType::Defi)
120    }
121
122    #[new]
123    fn py_new(value: &str) -> PyResult<Self> {
124        value
125            .parse::<NautilusDataType>()
126            .map(Self::new)
127            .map_err(to_pyruntime_err)
128    }
129
130    #[staticmethod]
131    #[pyo3(name = "Custom")]
132    fn py_custom(type_name: String) -> Self {
133        Self::new(NautilusDataType::Custom { type_name })
134    }
135
136    #[getter]
137    fn type_name(&self) -> Option<&str> {
138        match &self.inner {
139            NautilusDataType::Custom { type_name } => Some(type_name),
140            _ => None,
141        }
142    }
143
144    fn __str__(&self) -> String {
145        self.inner.to_string()
146    }
147
148    fn __repr__(&self) -> String {
149        match &self.inner {
150            NautilusDataType::Custom { type_name } => {
151                format!("NautilusDataType.Custom({type_name:?})")
152            }
153            _ => format!("NautilusDataType.{}", self.inner),
154        }
155    }
156
157    fn __richcmp__(&self, other: &Self, op: pyo3::pyclass::CompareOp, py: Python<'_>) -> Py<PyAny> {
158        use nautilus_core::python::IntoPyObjectNautilusExt;
159
160        match op {
161            pyo3::pyclass::CompareOp::Eq => (self.inner == other.inner).into_py_any_unwrap(py),
162            pyo3::pyclass::CompareOp::Ne => (self.inner != other.inner).into_py_any_unwrap(py),
163            _ => py.NotImplemented(),
164        }
165    }
166
167    fn __hash__(&self) -> isize {
168        let mut hasher = DefaultHasher::new();
169        self.inner.hash(&mut hasher);
170        hasher.finish() as isize
171    }
172}
173
174#[derive(Clone, Debug, Eq, Hash, PartialEq)]
175#[pyclass(frozen, name = "NautilusRecordType", module = "nautilus_trader.model")]
176#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")]
177pub struct PyNautilusRecordType {
178    inner: NautilusRecordType,
179}
180
181impl PyNautilusRecordType {
182    #[must_use]
183    pub const fn new(inner: NautilusRecordType) -> Self {
184        Self { inner }
185    }
186
187    #[must_use]
188    pub fn into_inner(self) -> NautilusRecordType {
189        self.inner
190    }
191
192    #[must_use]
193    pub const fn inner(&self) -> NautilusRecordType {
194        self.inner
195    }
196}
197
198#[pymethods]
199#[pyo3_stub_gen::derive::gen_stub_pymethods]
200impl PyNautilusRecordType {
201    #[new]
202    fn py_new(value: &str) -> PyResult<Self> {
203        value
204            .parse::<NautilusRecordType>()
205            .map(Self::new)
206            .map_err(to_pyruntime_err)
207    }
208
209    fn __str__(&self) -> String {
210        self.inner.to_string()
211    }
212
213    fn __repr__(&self) -> String {
214        format!("NautilusRecordType.{}", self.inner)
215    }
216
217    fn __richcmp__(&self, other: &Self, op: pyo3::pyclass::CompareOp, py: Python<'_>) -> Py<PyAny> {
218        use nautilus_core::python::IntoPyObjectNautilusExt;
219
220        match op {
221            pyo3::pyclass::CompareOp::Eq => (self.inner == other.inner).into_py_any_unwrap(py),
222            pyo3::pyclass::CompareOp::Ne => (self.inner != other.inner).into_py_any_unwrap(py),
223            _ => py.NotImplemented(),
224        }
225    }
226
227    fn __hash__(&self) -> isize {
228        let mut hasher = DefaultHasher::new();
229        self.inner.hash(&mut hasher);
230        hasher.finish() as isize
231    }
232}
233
234/// Converts a [`Data`] value into its native PyO3 object.
235///
236/// # Errors
237///
238/// Returns an error if the operation encounters data variants without a Python representation.
239pub fn data_to_pyobject(py: Python<'_>, data: Data) -> PyResult<Py<PyAny>> {
240    match data {
241        Data::Custom(custom) => Py::new(py, custom).map(Py::into_any),
242        Data::Instrument(instrument) => instrument_any_to_pyobject(py, *instrument),
243        Data::Quote(quote) => Py::new(py, quote).map(Py::into_any),
244        Data::Trade(trade) => Py::new(py, trade).map(Py::into_any),
245        Data::Bar(bar) => Py::new(py, bar).map(Py::into_any),
246        Data::BookDelta(delta) => Py::new(py, delta).map(Py::into_any),
247        Data::BookDeltas(deltas) => Py::new(py, (*deltas).clone()).map(Py::into_any),
248        Data::BookDepth(depth) => Py::new(py, *depth).map(Py::into_any),
249        Data::MarkPrice(price) => Py::new(py, price).map(Py::into_any),
250        Data::IndexPrice(price) => Py::new(py, price).map(Py::into_any),
251        Data::FundingRate(funding) => Py::new(py, funding).map(Py::into_any),
252        Data::OptionGreeks(greeks) => Py::new(py, greeks).map(Py::into_any),
253        Data::InstrumentStatus(status) => Py::new(py, status).map(Py::into_any),
254        Data::InstrumentClose(close) => Py::new(py, close).map(Py::into_any),
255        #[cfg(feature = "defi")]
256        Data::Defi(_) => Err(to_pytype_err("Unsupported DeFi data variant")),
257    }
258}
259
260/// Transforms the given Python objects into a vector of [`OrderBookDelta`] objects.
261///
262/// # Errors
263///
264/// Returns an error if element conversion fails or the data is not monotonically increasing.
265pub fn pyobjects_to_book_deltas(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<OrderBookDelta>> {
266    let deltas: Vec<OrderBookDelta> = data
267        .into_iter()
268        .map(|obj| obj.extract::<OrderBookDelta>().map_err(PyErr::from))
269        .collect::<PyResult<Vec<OrderBookDelta>>>()?;
270
271    // Validate monotonically increasing
272    if !is_monotonically_increasing_by_init(&deltas) {
273        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
274    }
275
276    Ok(deltas)
277}
278
279/// Transforms the given Python objects into a vector of [`QuoteTick`] objects.
280///
281/// # Errors
282///
283/// Returns an error if element conversion fails or the data is not monotonically increasing.
284pub fn pyobjects_to_quotes(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<QuoteTick>> {
285    let quotes: Vec<QuoteTick> = data
286        .into_iter()
287        .map(|obj| obj.extract::<QuoteTick>().map_err(PyErr::from))
288        .collect::<PyResult<Vec<QuoteTick>>>()?;
289
290    // Validate monotonically increasing
291    if !is_monotonically_increasing_by_init(&quotes) {
292        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
293    }
294
295    Ok(quotes)
296}
297
298/// Transforms the given Python objects into a vector of [`TradeTick`] objects.
299///
300/// # Errors
301///
302/// Returns an error if element conversion fails or the data is not monotonically increasing.
303pub fn pyobjects_to_trades(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<TradeTick>> {
304    let trades: Vec<TradeTick> = data
305        .into_iter()
306        .map(|obj| obj.extract::<TradeTick>().map_err(PyErr::from))
307        .collect::<PyResult<Vec<TradeTick>>>()?;
308
309    // Validate monotonically increasing
310    if !is_monotonically_increasing_by_init(&trades) {
311        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
312    }
313
314    Ok(trades)
315}
316
317/// Transforms the given Python objects into a vector of [`Bar`] objects.
318///
319/// # Errors
320///
321/// Returns an error if element conversion fails or the data is not monotonically increasing.
322pub fn pyobjects_to_bars(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<Bar>> {
323    let bars: Vec<Bar> = data
324        .into_iter()
325        .map(|obj| obj.extract::<Bar>().map_err(PyErr::from))
326        .collect::<PyResult<Vec<Bar>>>()?;
327
328    // Validate monotonically increasing
329    if !is_monotonically_increasing_by_init(&bars) {
330        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
331    }
332
333    Ok(bars)
334}
335
336/// Transforms the given Python objects into a vector of [`MarkPriceUpdate`] objects.
337///
338/// # Errors
339///
340/// Returns an error if element conversion fails or the data is not monotonically increasing.
341pub fn pyobjects_to_mark_prices(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<MarkPriceUpdate>> {
342    let mark_prices: Vec<MarkPriceUpdate> = data
343        .into_iter()
344        .map(|obj| obj.extract::<MarkPriceUpdate>().map_err(PyErr::from))
345        .collect::<PyResult<Vec<MarkPriceUpdate>>>()?;
346
347    // Validate monotonically increasing
348    if !is_monotonically_increasing_by_init(&mark_prices) {
349        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
350    }
351
352    Ok(mark_prices)
353}
354
355/// Transforms the given Python objects into a vector of [`IndexPriceUpdate`] objects.
356///
357/// # Errors
358///
359/// Returns an error if element conversion fails or the data is not monotonically increasing.
360pub fn pyobjects_to_index_prices(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<IndexPriceUpdate>> {
361    let index_prices: Vec<IndexPriceUpdate> = data
362        .into_iter()
363        .map(|obj| obj.extract::<IndexPriceUpdate>().map_err(PyErr::from))
364        .collect::<PyResult<Vec<IndexPriceUpdate>>>()?;
365
366    // Validate monotonically increasing
367    if !is_monotonically_increasing_by_init(&index_prices) {
368        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
369    }
370
371    Ok(index_prices)
372}
373
374/// Transforms the given Python objects into a vector of [`InstrumentStatus`] objects.
375///
376/// # Errors
377///
378/// Returns an error if element conversion fails or the data is not monotonically increasing.
379pub fn pyobjects_to_instrument_statuses(
380    data: Vec<Bound<'_, PyAny>>,
381) -> PyResult<Vec<InstrumentStatus>> {
382    let statuses: Vec<InstrumentStatus> = data
383        .into_iter()
384        .map(|obj| obj.extract::<InstrumentStatus>().map_err(PyErr::from))
385        .collect::<PyResult<Vec<InstrumentStatus>>>()?;
386
387    if !is_monotonically_increasing_by_init(&statuses) {
388        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
389    }
390
391    Ok(statuses)
392}
393
394/// Transforms the given Python objects into a vector of [`OptionGreeks`] objects.
395///
396/// # Errors
397///
398/// Returns an error if element conversion fails or the data is not monotonically increasing.
399pub fn pyobjects_to_option_greeks(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<OptionGreeks>> {
400    let greeks: Vec<OptionGreeks> = data
401        .into_iter()
402        .map(|obj| obj.extract::<OptionGreeks>().map_err(PyErr::from))
403        .collect::<PyResult<Vec<OptionGreeks>>>()?;
404
405    if !is_monotonically_increasing_by_init(&greeks) {
406        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
407    }
408
409    Ok(greeks)
410}
411
412/// Transforms the given Python objects into a vector of [`InstrumentClose`] objects.
413///
414/// # Errors
415///
416/// Returns an error if element conversion fails or the data is not monotonically increasing.
417pub fn pyobjects_to_instrument_closes(
418    data: Vec<Bound<'_, PyAny>>,
419) -> PyResult<Vec<InstrumentClose>> {
420    let closes: Vec<InstrumentClose> = data
421        .into_iter()
422        .map(|obj| obj.extract::<InstrumentClose>().map_err(PyErr::from))
423        .collect::<PyResult<Vec<InstrumentClose>>>()?;
424
425    // Validate monotonically increasing
426    if !is_monotonically_increasing_by_init(&closes) {
427        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
428    }
429
430    Ok(closes)
431}
432
433/// Deserializes custom data from JSON bytes into a PyO3 `CustomData` wrapper.
434///
435/// # Errors
436///
437/// Returns an error if the type is not registered or JSON deserialization fails.
438#[cfg(feature = "python")]
439#[pyfunction]
440pub fn deserialize_custom_from_json(type_name: &str, payload: &[u8]) -> PyResult<CustomData> {
441    use crate::data::registry;
442    let value: serde_json::Value = serde_json::from_slice(payload)
443        .map_err(|e| to_pyvalue_err(format!("Invalid JSON: {e}")))?;
444    let Some(Data::Custom(custom)) = registry::deserialize_custom_from_json(type_name, &value)
445        .map_err(|e| to_pyvalue_err(format!("Deserialization failed: {e}")))?
446    else {
447        return Err(to_pyvalue_err(format!(
448            "Custom data type \"{type_name}\" is not registered"
449        )));
450    };
451    Ok(custom)
452}
453
454/// Deserializes JSON value to `CustomData` via the data class's `from_json`.
455#[cfg(feature = "python")]
456fn py_json_deserialize_custom_data(
457    data_class: &pyo3::Py<pyo3::PyAny>,
458    value: &serde_json::Value,
459) -> Result<std::sync::Arc<dyn crate::data::CustomDataTrait>, anyhow::Error> {
460    use std::sync::Arc;
461
462    use crate::data::PythonCustomDataWrapper;
463
464    pyo3::Python::attach(|py| {
465        let json_str = serde_json::to_string(&value)?;
466        let json_module = py
467            .import("json")
468            .map_err(|e| anyhow::anyhow!("Failed to import json: {e}"))?;
469        let py_dict = json_module
470            .call_method1("loads", (json_str,))
471            .map_err(|e| anyhow::anyhow!("Failed to parse JSON: {e}"))?;
472
473        let instance = data_class
474            .bind(py)
475            .call_method1("from_json", (py_dict,))
476            .map_err(|e| anyhow::anyhow!("Failed to call from_json: {e}"))?;
477
478        let wrapper = PythonCustomDataWrapper::new(py, &instance)
479            .map_err(|e| anyhow::anyhow!("Failed to create wrapper: {e}"))?;
480
481        Ok(Arc::new(wrapper) as Arc<dyn crate::data::CustomDataTrait>)
482    })
483}
484
485/// Encodes `CustomData` items to `RecordBatch` via Python `encode_record_batch_py`.
486#[allow(unsafe_code)]
487#[cfg(all(feature = "python", feature = "arrow"))]
488fn py_encode_custom_data_to_record_batch(
489    items: &[std::sync::Arc<dyn crate::data::CustomDataTrait>],
490) -> Result<arrow::record_batch::RecordBatch, anyhow::Error> {
491    pyo3::Python::attach(|py| {
492        let py_items: Result<Vec<_>, _> = items.iter().map(|item| item.to_pyobject(py)).collect();
493        let py_items = py_items.map_err(|e| anyhow::anyhow!("Failed to convert to Python: {e}"))?;
494        let py_list = pyo3::types::PyList::new(py, &py_items)
495            .map_err(|e| anyhow::anyhow!("Failed to create list: {e}"))?;
496
497        let first = items
498            .first()
499            .ok_or_else(|| anyhow::anyhow!("No items to encode"))?;
500        let first_py = first.to_pyobject(py)?;
501
502        if first_py
503            .bind(py)
504            .hasattr("encode_record_batch_py")
505            .unwrap_or(false)
506        {
507            let py_batch = first_py
508                .bind(py)
509                .call_method1("encode_record_batch_py", (py_list,))
510                .map_err(|e| anyhow::anyhow!("Failed to call encode_record_batch_py: {e}"))?;
511
512            let mut ffi_array = arrow::ffi::FFI_ArrowArray::empty();
513            let mut ffi_schema = arrow::ffi::FFI_ArrowSchema::empty();
514
515            py_batch.call_method1(
516                "_export_to_c",
517                (
518                    (&raw mut ffi_array as usize),
519                    (&raw mut ffi_schema as usize),
520                ),
521            )?;
522
523            let schema = std::sync::Arc::new(arrow::datatypes::Schema::try_from(&ffi_schema)?);
524            let struct_array_data = unsafe {
525                arrow::ffi::from_ffi_and_data_type(
526                    ffi_array,
527                    arrow::datatypes::DataType::Struct(schema.fields().clone()),
528                )?
529            };
530            let struct_array = arrow::array::StructArray::from(struct_array_data);
531            Ok(arrow::record_batch::RecordBatch::from(&struct_array))
532        } else {
533            anyhow::bail!("Instances must have encode_record_batch_py method")
534        }
535    })
536}
537
538#[cfg(all(feature = "python", feature = "arrow"))]
539fn pyarrow_schema_to_arrow_schema(
540    py_schema: &pyo3::Bound<'_, pyo3::PyAny>,
541) -> PyResult<arrow::datatypes::Schema> {
542    let mut ffi_schema = arrow::ffi::FFI_ArrowSchema::empty();
543    py_schema.call_method1("_export_to_c", ((&raw mut ffi_schema as usize),))?;
544    arrow::datatypes::Schema::try_from(&ffi_schema)
545        .map_err(|e| to_pyvalue_err(format!("Failed to import PyArrow schema: {e}")))
546}
547
548/// Decodes `RecordBatch` to `CustomData` via Python `decode_record_batch_py`.
549#[allow(unsafe_code)]
550#[cfg(all(feature = "python", feature = "arrow"))]
551fn py_decode_record_batch_to_custom_data(
552    data_class: &pyo3::Py<pyo3::PyAny>,
553    metadata: &std::collections::HashMap<String, String>,
554    batch: arrow::record_batch::RecordBatch,
555) -> Result<Vec<crate::data::Data>, anyhow::Error> {
556    use std::sync::Arc;
557
558    use crate::data::PythonCustomDataWrapper;
559
560    pyo3::Python::attach(|py| {
561        let struct_array: arrow::array::StructArray = batch.into();
562        let array_data = arrow::array::Array::to_data(&struct_array);
563        let mut ffi_array = arrow::ffi::FFI_ArrowArray::new(&array_data);
564        let fields = match arrow::array::Array::data_type(&struct_array) {
565            arrow::datatypes::DataType::Struct(f) => f.clone(),
566            _ => unreachable!(),
567        };
568        let mut ffi_schema =
569            arrow::ffi::FFI_ArrowSchema::try_from(arrow::datatypes::DataType::Struct(fields))?;
570
571        let pyarrow = py.import("pyarrow")?;
572        let cls = pyarrow.getattr("RecordBatch")?;
573        let py_batch = cls.call_method1(
574            "_import_from_c",
575            (
576                (&raw mut ffi_array as usize),
577                (&raw mut ffi_schema as usize),
578            ),
579        )?;
580
581        let metadata_py = pyo3::types::PyDict::new(py);
582        for (k, v) in metadata {
583            metadata_py.set_item(k, v)?;
584        }
585
586        let py_list = data_class
587            .bind(py)
588            .call_method1("decode_record_batch_py", (metadata_py, py_batch))
589            .map_err(|e| anyhow::anyhow!("Failed to call decode_record_batch_py: {e}"))?;
590
591        let list = py_list
592            .cast::<pyo3::types::PyList>()
593            .map_err(|_| anyhow::anyhow!("Expected list from decode_record_batch_py"))?;
594
595        let mut result = Vec::new();
596        for item in list.iter() {
597            let wrapper = PythonCustomDataWrapper::new(py, &item)
598                .map_err(|e| anyhow::anyhow!("Failed to create wrapper: {e}"))?;
599            result.push(crate::data::Data::Custom(
600                crate::data::CustomData::from_arc(Arc::new(wrapper)),
601            ));
602        }
603        Ok(result)
604    })
605}
606
607/// Registers a custom data **type** (class) with the catalog registry.
608///
609/// Use this when you prefer to pass the class instead of a sample instance.
610/// The class must have:
611/// - `type_name_static()` class method or `__name__` (used as type name in storage)
612/// - `decode_record_batch_py(metadata, batch)` class method
613/// - Instances must have `ts_event`, `ts_init`, and `encode_record_batch_py(items)`.
614///
615/// To write the type to a catalog and query it back, the class must also supply the Arrow
616/// schema its batches use, through a `_schema` class attribute or an `arrow_schema_py()`
617/// class method. That schema must contain `ts_init`. Any `ts_event` or `ts_init` fields must use
618/// `timestamp("ns", tz="UTC")`. The `@customdataclass` decorator generates both the schema
619/// and the Arrow methods. Without a usable schema the class still registers for JSON use, and
620/// `write_custom_data` raises rather than writing a file that cannot be queried.
621///
622/// # Arguments
623///
624/// - `data_class` - The custom data class (e.g. `MarketTickPython` or `module.MarketTickData`)
625///
626/// # Errors
627///
628/// Returns an error if the class lacks required methods, or if a Rust custom data type with a
629/// different class already uses the type name.
630///
631/// # Example
632///
633/// ```python
634/// from nautilus_trader.model.custom import customdataclass
635/// from nautilus_trader.model import register_custom_data_class
636///
637/// @customdataclass()
638/// class MarketTickPython:
639///     symbol: str = ""
640///     price: float = 0.0
641///     volume: int = 0
642///
643/// register_custom_data_class(MarketTickPython)
644/// ```
645#[cfg(feature = "python")]
646#[pyfunction]
647#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.model")]
648pub fn register_custom_data_class(data_class: &Bound<'_, PyAny>) -> PyResult<()> {
649    use std::sync::Arc;
650
651    use crate::data::registry;
652
653    let _py = data_class.py();
654
655    let type_name: String = if data_class.hasattr("type_name_static")? {
656        data_class.call_method0("type_name_static")?.extract()?
657    } else {
658        data_class.getattr("__name__")?.extract()?
659    };
660
661    if registry::conflicts_with_rust_class(&type_name, data_class) {
662        return Err(to_pyvalue_err(format!(
663            "Custom data type \"{type_name}\" is already registered for a different Rust class"
664        )));
665    }
666
667    #[cfg(feature = "arrow")]
668    if !data_class.hasattr("decode_record_batch_py")? {
669        return Err(to_pytype_err(
670            "Custom data class must have decode_record_batch_py(metadata, batch) class method",
671        ));
672    }
673
674    if !data_class.hasattr("from_json")? {
675        return Err(to_pytype_err(
676            "Custom data class must have from_json(data) class method (Rust macro provides it)",
677        ));
678    }
679
680    register_python_data_class(&type_name, data_class);
681
682    if let Some(extractor) = registry::get_rust_extractor(&type_name) {
683        let _ = registry::ensure_py_extractor_registered(&type_name, extractor);
684    }
685
686    let data_class_for_json = data_class.clone().unbind();
687
688    let json_deserializer = Box::new(
689        move |value: serde_json::Value| -> Result<Arc<dyn crate::data::CustomDataTrait>, anyhow::Error> {
690            pyo3::Python::attach(|py| {
691                py_json_deserialize_custom_data(&data_class_for_json.clone_ref(py), &value)
692            })
693        },
694    );
695
696    registry::ensure_json_deserializer_registered(&type_name, json_deserializer).map_err(|e| {
697        to_pyruntime_err(format!(
698            "Failed to register JSON deserializer for {type_name}: {e}"
699        ))
700    })?;
701
702    #[cfg(feature = "arrow")]
703    {
704        let data_class_for_decode = data_class.clone().unbind();
705        let pyarrow_schema = if let Ok(schema) = data_class.getattr("_schema") {
706            Some(schema)
707        } else if data_class.hasattr("arrow_schema_py")? {
708            Some(data_class.call_method0("arrow_schema_py")?)
709        } else {
710            None
711        }
712        .filter(|schema| schema.hasattr("_export_to_c").unwrap_or(false));
713        let schema = if let Some(py_schema) = pyarrow_schema {
714            Arc::new(pyarrow_schema_to_arrow_schema(&py_schema)?)
715        } else if let Some(schema) = registry::get_arrow_schema(&type_name) {
716            schema
717        } else {
718            Arc::new(arrow::datatypes::Schema::empty())
719        };
720        registry::validate_custom_arrow_schema(&type_name, &schema, false)
721            .map_err(|e| to_pyvalue_err(e.to_string()))?;
722
723        let encoder = Box::new(
724            move |items: &[Arc<dyn crate::data::CustomDataTrait>]| -> Result<
725                arrow::record_batch::RecordBatch,
726                anyhow::Error,
727            > { py_encode_custom_data_to_record_batch(items) },
728        );
729
730        let decoder = Box::new(
731            move |metadata: &std::collections::HashMap<String, String>,
732                  batch: arrow::record_batch::RecordBatch|
733                  -> Result<Vec<crate::data::Data>, anyhow::Error> {
734                pyo3::Python::attach(|py| {
735                    py_decode_record_batch_to_custom_data(
736                        &data_class_for_decode.clone_ref(py),
737                        metadata,
738                        batch,
739                    )
740                })
741            },
742        );
743
744        registry::ensure_arrow_registered(&type_name, schema, encoder, decoder).map_err(|e| {
745            to_pyruntime_err(format!(
746                "Failed to register Arrow encoder/decoder for {type_name}: {e}"
747            ))
748        })?;
749    }
750
751    Ok(())
752}
753
754/// Transforms the given Python objects into a vector of [`FundingRateUpdate`] objects.
755///
756/// # Errors
757///
758/// Returns an error if element conversion fails or the data is not monotonically increasing.
759pub fn pyobjects_to_funding_rates(data: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<FundingRateUpdate>> {
760    let funding_rates: Vec<FundingRateUpdate> = data
761        .into_iter()
762        .map(|obj| obj.extract::<FundingRateUpdate>().map_err(PyErr::from))
763        .collect::<PyResult<Vec<FundingRateUpdate>>>()?;
764
765    // Validate monotonically increasing
766    if !is_monotonically_increasing_by_init(&funding_rates) {
767        return Err(to_pyvalue_err(ERROR_MONOTONICITY));
768    }
769
770    Ok(funding_rates)
771}
772
773#[cfg(test)]
774mod tests {
775    use std::sync::Once;
776
777    use rstest::rstest;
778
779    use super::*;
780    use crate::data::{
781        OrderBookDeltas, OrderBookDepth,
782        stubs::{
783            quote_audusd, stub_bar, stub_delta, stub_deltas, stub_depth10, stub_trade_ethusdt_buy,
784        },
785    };
786
787    fn ensure_python_initialized() {
788        static INIT: Once = Once::new();
789        INIT.call_once(Python::initialize);
790    }
791
792    #[rstest]
793    fn data_to_pyobject_preserves_built_in_data() {
794        ensure_python_initialized();
795
796        let expected_delta = stub_delta();
797        let expected_deltas = stub_deltas();
798        let expected_depth = stub_depth10();
799        let expected_quote = quote_audusd();
800        let expected_trade = stub_trade_ethusdt_buy();
801        let expected_bar = stub_bar();
802
803        Python::attach(|py| {
804            let py_delta = data_to_pyobject(py, Data::BookDelta(expected_delta)).unwrap();
805            let py_deltas =
806                data_to_pyobject(py, Data::BookDeltas(Box::new(expected_deltas.clone()))).unwrap();
807            let py_depth =
808                data_to_pyobject(py, Data::BookDepth(Box::new(expected_depth.clone()))).unwrap();
809            let py_quote = data_to_pyobject(py, Data::Quote(expected_quote)).unwrap();
810            let py_trade = data_to_pyobject(py, Data::Trade(expected_trade)).unwrap();
811            let py_bar = data_to_pyobject(py, Data::Bar(expected_bar)).unwrap();
812
813            let actual_delta = *py_delta.bind(py).cast::<OrderBookDelta>().unwrap().borrow();
814            let actual_deltas = py_deltas
815                .bind(py)
816                .cast::<OrderBookDeltas>()
817                .unwrap()
818                .borrow()
819                .clone();
820            let actual_depth = py_depth
821                .bind(py)
822                .cast::<OrderBookDepth>()
823                .unwrap()
824                .borrow()
825                .clone();
826            let actual_quote = *py_quote.bind(py).cast::<QuoteTick>().unwrap().borrow();
827            let actual_trade = *py_trade.bind(py).cast::<TradeTick>().unwrap().borrow();
828            let actual_bar = *py_bar.bind(py).cast::<Bar>().unwrap().borrow();
829
830            assert_eq!(actual_delta, expected_delta);
831            assert_eq!(actual_deltas.instrument_id, expected_deltas.instrument_id);
832            assert_eq!(actual_deltas.deltas, expected_deltas.deltas);
833            assert_eq!(actual_deltas.flags, expected_deltas.flags);
834            assert_eq!(actual_deltas.sequence, expected_deltas.sequence);
835            assert_eq!(actual_deltas.ts_event, expected_deltas.ts_event);
836            assert_eq!(actual_deltas.ts_init, expected_deltas.ts_init);
837            assert_eq!(actual_depth, expected_depth);
838            assert_eq!(actual_quote, expected_quote);
839            assert_eq!(actual_trade, expected_trade);
840            assert_eq!(actual_bar, expected_bar);
841        });
842    }
843
844    #[cfg(feature = "defi")]
845    #[rstest]
846    fn data_to_pyobject_rejects_defi_variant() {
847        use nautilus_core::UnixNanos;
848        use ustr::Ustr;
849
850        use crate::defi::{Blockchain, data::Block};
851
852        ensure_python_initialized();
853
854        let block = Block::new(
855            "0x1234".to_string(),
856            "0xabcd".to_string(),
857            42,
858            Ustr::from("0x0000000000000000000000000000000000000000"),
859            100_000,
860            50_000,
861            UnixNanos::from(1_700_000_000u64),
862            Some(Blockchain::Ethereum),
863        );
864
865        Python::attach(|py| {
866            let e = data_to_pyobject(
867                py,
868                Data::Defi(Box::new(crate::defi::data::DefiData::Block(block))),
869            )
870            .unwrap_err();
871
872            assert_eq!(e.to_string(), "TypeError: Unsupported DeFi data variant");
873        });
874    }
875}