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