nautilus_model/python/data/
trade.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{
17    collections::{HashMap, hash_map::DefaultHasher},
18    hash::{Hash, Hasher},
19    str::FromStr,
20};
21
22use nautilus_core::{
23    UnixNanos,
24    python::{
25        IntoPyObjectNautilusExt,
26        serialization::{from_dict_pyo3, to_dict_pyo3},
27        to_pyvalue_err,
28    },
29    serialization::Serializable,
30};
31use pyo3::{
32    IntoPyObjectExt,
33    prelude::*,
34    pyclass::CompareOp,
35    types::{PyDict, PyInt, PyString, PyTuple},
36};
37
38use super::data_to_pycapsule;
39use crate::{
40    data::{Data, TradeTick},
41    enums::{AggressorSide, FromU8},
42    identifiers::{InstrumentId, TradeId},
43    python::common::PY_MODULE_MODEL,
44    types::{
45        price::{Price, PriceRaw},
46        quantity::{Quantity, QuantityRaw},
47    },
48};
49
50impl TradeTick {
51    /// Create a new [`TradeTick`] extracted from the given [`PyAny`].
52    pub fn from_pyobject(obj: &Bound<'_, PyAny>) -> PyResult<Self> {
53        let instrument_id_obj: Bound<'_, PyAny> = obj.getattr("instrument_id")?.extract()?;
54        let instrument_id_str: String = instrument_id_obj.getattr("value")?.extract()?;
55        let instrument_id =
56            InstrumentId::from_str(instrument_id_str.as_str()).map_err(to_pyvalue_err)?;
57
58        let price_py: Bound<'_, PyAny> = obj.getattr("price")?.extract()?;
59        let price_raw: PriceRaw = price_py.getattr("raw")?.extract()?;
60        let price_prec: u8 = price_py.getattr("precision")?.extract()?;
61        let price = Price::from_raw(price_raw, price_prec);
62
63        let size_py: Bound<'_, PyAny> = obj.getattr("size")?.extract()?;
64        let size_raw: QuantityRaw = size_py.getattr("raw")?.extract()?;
65        let size_prec: u8 = size_py.getattr("precision")?.extract()?;
66        let size = Quantity::from_raw(size_raw, size_prec);
67
68        let aggressor_side_obj: Bound<'_, PyAny> = obj.getattr("aggressor_side")?.extract()?;
69        let aggressor_side_u8 = aggressor_side_obj.getattr("value")?.extract()?;
70        let aggressor_side = AggressorSide::from_u8(aggressor_side_u8).unwrap();
71
72        let trade_id_obj: Bound<'_, PyAny> = obj.getattr("trade_id")?.extract()?;
73        let trade_id_str: String = trade_id_obj.getattr("value")?.extract()?;
74        let trade_id = TradeId::from(trade_id_str.as_str());
75
76        let ts_event: u64 = obj.getattr("ts_event")?.extract()?;
77        let ts_init: u64 = obj.getattr("ts_init")?.extract()?;
78
79        Ok(Self::new(
80            instrument_id,
81            price,
82            size,
83            aggressor_side,
84            trade_id,
85            ts_event.into(),
86            ts_init.into(),
87        ))
88    }
89}
90
91#[pymethods]
92impl TradeTick {
93    #[new]
94    fn py_new(
95        instrument_id: InstrumentId,
96        price: Price,
97        size: Quantity,
98        aggressor_side: AggressorSide,
99        trade_id: TradeId,
100        ts_event: u64,
101        ts_init: u64,
102    ) -> PyResult<Self> {
103        Self::new_checked(
104            instrument_id,
105            price,
106            size,
107            aggressor_side,
108            trade_id,
109            ts_event.into(),
110            ts_init.into(),
111        )
112        .map_err(to_pyvalue_err)
113    }
114
115    fn __setstate__(&mut self, state: &Bound<'_, PyAny>) -> PyResult<()> {
116        let py_tuple: &Bound<'_, PyTuple> = state.downcast::<PyTuple>()?;
117        let binding = py_tuple.get_item(0)?;
118        let instrument_id_str = binding.downcast::<PyString>()?.extract::<&str>()?;
119        let price_raw = py_tuple
120            .get_item(1)?
121            .downcast::<PyInt>()?
122            .extract::<PriceRaw>()?;
123        let price_prec = py_tuple.get_item(2)?.downcast::<PyInt>()?.extract::<u8>()?;
124        let size_raw = py_tuple
125            .get_item(3)?
126            .downcast::<PyInt>()?
127            .extract::<QuantityRaw>()?;
128        let size_prec = py_tuple.get_item(4)?.downcast::<PyInt>()?.extract::<u8>()?;
129
130        let aggressor_side_u8 = py_tuple.get_item(5)?.downcast::<PyInt>()?.extract::<u8>()?;
131        let binding = py_tuple.get_item(6)?;
132        let trade_id_str = binding.downcast::<PyString>()?.extract::<&str>()?;
133        let ts_event = py_tuple
134            .get_item(7)?
135            .downcast::<PyInt>()?
136            .extract::<u64>()?;
137        let ts_init = py_tuple
138            .get_item(8)?
139            .downcast::<PyInt>()?
140            .extract::<u64>()?;
141
142        self.instrument_id = InstrumentId::from_str(instrument_id_str).map_err(to_pyvalue_err)?;
143        self.price = Price::from_raw(price_raw, price_prec);
144        self.size = Quantity::from_raw(size_raw, size_prec);
145        self.aggressor_side = AggressorSide::from_u8(aggressor_side_u8).unwrap();
146        self.trade_id = TradeId::from(trade_id_str);
147        self.ts_event = ts_event.into();
148        self.ts_init = ts_init.into();
149
150        Ok(())
151    }
152
153    fn __getstate__(&self, py: Python) -> PyResult<PyObject> {
154        (
155            self.instrument_id.to_string(),
156            self.price.raw,
157            self.price.precision,
158            self.size.raw,
159            self.size.precision,
160            self.aggressor_side as u8,
161            self.trade_id.to_string(),
162            self.ts_event.as_u64(),
163            self.ts_init.as_u64(),
164        )
165            .into_py_any(py)
166    }
167
168    fn __reduce__(&self, py: Python) -> PyResult<PyObject> {
169        let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
170        let state = self.__getstate__(py)?;
171        (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
172    }
173
174    #[staticmethod]
175    fn _safe_constructor() -> Self {
176        Self::new(
177            InstrumentId::from("NULL.NULL"),
178            Price::zero(0),
179            Quantity::from(1), // size cannot be zero
180            AggressorSide::NoAggressor,
181            TradeId::from("NULL"),
182            UnixNanos::default(),
183            UnixNanos::default(),
184        )
185    }
186
187    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
188        match op {
189            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
190            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
191            _ => py.NotImplemented(),
192        }
193    }
194
195    fn __hash__(&self) -> isize {
196        let mut h = DefaultHasher::new();
197        self.hash(&mut h);
198        h.finish() as isize
199    }
200
201    fn __repr__(&self) -> String {
202        format!("{}({})", stringify!(TradeTick), self)
203    }
204
205    fn __str__(&self) -> String {
206        self.to_string()
207    }
208
209    #[getter]
210    #[pyo3(name = "instrument_id")]
211    fn py_instrument_id(&self) -> InstrumentId {
212        self.instrument_id
213    }
214
215    #[getter]
216    #[pyo3(name = "price")]
217    fn py_price(&self) -> Price {
218        self.price
219    }
220
221    #[getter]
222    #[pyo3(name = "size")]
223    fn py_size(&self) -> Quantity {
224        self.size
225    }
226
227    #[getter]
228    #[pyo3(name = "aggressor_side")]
229    fn py_aggressor_side(&self) -> AggressorSide {
230        self.aggressor_side
231    }
232
233    #[getter]
234    #[pyo3(name = "trade_id")]
235    fn py_trade_id(&self) -> TradeId {
236        self.trade_id
237    }
238
239    #[getter]
240    #[pyo3(name = "ts_event")]
241    fn py_ts_event(&self) -> u64 {
242        self.ts_event.as_u64()
243    }
244
245    #[getter]
246    #[pyo3(name = "ts_init")]
247    fn py_ts_init(&self) -> u64 {
248        self.ts_init.as_u64()
249    }
250
251    #[staticmethod]
252    #[pyo3(name = "fully_qualified_name")]
253    fn py_fully_qualified_name() -> String {
254        format!("{}:{}", PY_MODULE_MODEL, stringify!(TradeTick))
255    }
256
257    #[staticmethod]
258    #[pyo3(name = "get_metadata")]
259    fn py_get_metadata(
260        instrument_id: &InstrumentId,
261        price_precision: u8,
262        size_precision: u8,
263    ) -> PyResult<HashMap<String, String>> {
264        Ok(Self::get_metadata(
265            instrument_id,
266            price_precision,
267            size_precision,
268        ))
269    }
270
271    #[staticmethod]
272    #[pyo3(name = "get_fields")]
273    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
274        let py_dict = PyDict::new(py);
275        for (k, v) in Self::get_fields() {
276            py_dict.set_item(k, v)?;
277        }
278
279        Ok(py_dict)
280    }
281
282    /// Returns a new object from the given dictionary representation.
283    #[staticmethod]
284    #[pyo3(name = "from_dict")]
285    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
286        from_dict_pyo3(py, values)
287    }
288
289    #[staticmethod]
290    #[pyo3(name = "from_json")]
291    fn py_from_json(data: Vec<u8>) -> PyResult<Self> {
292        Self::from_json_bytes(&data).map_err(to_pyvalue_err)
293    }
294
295    #[staticmethod]
296    #[pyo3(name = "from_msgpack")]
297    fn py_from_msgpack(data: Vec<u8>) -> PyResult<Self> {
298        Self::from_msgpack_bytes(&data).map_err(to_pyvalue_err)
299    }
300
301    /// Creates a `PyCapsule` containing a raw pointer to a `Data::Trade` object.
302    ///
303    /// This function takes the current object (assumed to be of a type that can be represented as
304    /// `Data::Trade`), and encapsulates a raw pointer to it within a `PyCapsule`.
305    ///
306    /// # Safety
307    ///
308    /// This function is safe as long as the following conditions are met:
309    /// - The `Data::Trade` object pointed to by the capsule must remain valid for the lifetime of the capsule.
310    /// - The consumer of the capsule must ensure proper handling to avoid dereferencing a dangling pointer.
311    ///
312    /// # Panics
313    ///
314    /// The function will panic if the `PyCapsule` creation fails, which can occur if the
315    /// `Data::Trade` object cannot be converted into a raw pointer.
316    #[pyo3(name = "as_pycapsule")]
317    fn py_as_pycapsule(&self, py: Python<'_>) -> PyObject {
318        data_to_pycapsule(py, Data::Trade(*self))
319    }
320
321    /// Return a dictionary representation of the object.
322    #[pyo3(name = "as_dict")]
323    fn py_as_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
324        to_dict_pyo3(py, self)
325    }
326
327    /// Return JSON encoded bytes representation of the object.
328    #[pyo3(name = "as_json")]
329    fn py_as_json(&self, py: Python<'_>) -> Py<PyAny> {
330        // SAFETY: Unwrap safe when serializing a valid object
331        self.as_json_bytes().unwrap().into_py_any_unwrap(py)
332    }
333
334    /// Return MsgPack encoded bytes representation of the object.
335    #[pyo3(name = "as_msgpack")]
336    fn py_as_msgpack(&self, py: Python<'_>) -> Py<PyAny> {
337        // SAFETY: Unwrap safe when serializing a valid object
338        self.as_msgpack_bytes().unwrap().into_py_any_unwrap(py)
339    }
340}
341
342////////////////////////////////////////////////////////////////////////////////
343// Tests
344////////////////////////////////////////////////////////////////////////////////
345#[cfg(test)]
346mod tests {
347    use nautilus_core::python::IntoPyObjectNautilusExt;
348    use pyo3::Python;
349    use rstest::rstest;
350
351    use crate::{
352        data::{TradeTick, stubs::stub_trade_ethusdt_buyer},
353        enums::AggressorSide,
354        identifiers::{InstrumentId, TradeId},
355        types::{Price, Quantity},
356    };
357
358    #[rstest]
359    fn test_trade_tick_py_new_with_zero_size() {
360        pyo3::prepare_freethreaded_python();
361
362        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
363        let price = Price::from("10000.00");
364        let zero_size = Quantity::from(0);
365        let aggressor_side = AggressorSide::Buyer;
366        let trade_id = TradeId::from("123456789");
367        let ts_event = 1;
368        let ts_init = 2;
369
370        let result = TradeTick::py_new(
371            instrument_id,
372            price,
373            zero_size,
374            aggressor_side,
375            trade_id,
376            ts_event,
377            ts_init,
378        );
379
380        assert!(result.is_err());
381    }
382
383    #[rstest]
384    fn test_as_dict(stub_trade_ethusdt_buyer: TradeTick) {
385        pyo3::prepare_freethreaded_python();
386        let trade = stub_trade_ethusdt_buyer;
387
388        Python::with_gil(|py| {
389            let dict_string = trade.py_as_dict(py).unwrap().to_string();
390            let expected_string = r"{'type': 'TradeTick', 'instrument_id': 'ETHUSDT-PERP.BINANCE', 'price': '10000.0000', 'size': '1.00000000', 'aggressor_side': 'BUYER', 'trade_id': '123456789', 'ts_event': 0, 'ts_init': 1}";
391            assert_eq!(dict_string, expected_string);
392        });
393    }
394
395    #[rstest]
396    fn test_from_dict(stub_trade_ethusdt_buyer: TradeTick) {
397        pyo3::prepare_freethreaded_python();
398        let trade = stub_trade_ethusdt_buyer;
399
400        Python::with_gil(|py| {
401            let dict = trade.py_as_dict(py).unwrap();
402            let parsed = TradeTick::py_from_dict(py, dict).unwrap();
403            assert_eq!(parsed, trade);
404        });
405    }
406
407    #[rstest]
408    fn test_from_pyobject(stub_trade_ethusdt_buyer: TradeTick) {
409        pyo3::prepare_freethreaded_python();
410        let trade = stub_trade_ethusdt_buyer;
411
412        Python::with_gil(|py| {
413            let tick_pyobject = trade.into_py_any_unwrap(py);
414            let parsed_tick = TradeTick::from_pyobject(tick_pyobject.bind(py)).unwrap();
415            assert_eq!(parsed_tick, trade);
416        });
417    }
418}