Skip to main content

nautilus_model/python/data/
trade.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
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::{
30        Serializable,
31        msgpack::{FromMsgPack, ToMsgPack},
32    },
33};
34use pyo3::{
35    IntoPyObjectExt,
36    prelude::*,
37    pyclass::CompareOp,
38    types::{PyDict, PyInt, PyString, PyTuple},
39};
40
41use crate::{
42    data::TradeTick,
43    enums::{AggressorSide, FromU8},
44    identifiers::{InstrumentId, TradeId},
45    python::common::PY_MODULE_MODEL,
46    types::{
47        price::{Price, PriceRaw},
48        quantity::{Quantity, QuantityRaw},
49    },
50};
51
52#[pymethods]
53#[pyo3_stub_gen::derive::gen_stub_pymethods]
54impl TradeTick {
55    /// Represents a trade tick in a market.
56    #[new]
57    fn py_new(
58        instrument_id: InstrumentId,
59        price: Price,
60        size: Quantity,
61        aggressor_side: AggressorSide,
62        trade_id: TradeId,
63        ts_event: u64,
64        ts_init: u64,
65    ) -> PyResult<Self> {
66        Self::new_checked(
67            instrument_id,
68            price,
69            size,
70            aggressor_side,
71            trade_id,
72            ts_event.into(),
73            ts_init.into(),
74        )
75        .map_err(to_pyvalue_err)
76    }
77
78    fn __setstate__(&mut self, state: &Bound<'_, PyAny>) -> PyResult<()> {
79        let py_tuple: &Bound<'_, PyTuple> = state.cast::<PyTuple>()?;
80        let binding = py_tuple.get_item(0)?;
81        let instrument_id_str = binding.cast::<PyString>()?.extract::<&str>()?;
82        let price_raw = py_tuple
83            .get_item(1)?
84            .cast::<PyInt>()?
85            .extract::<PriceRaw>()?;
86        let price_prec = py_tuple.get_item(2)?.cast::<PyInt>()?.extract::<u8>()?;
87        let size_raw = py_tuple
88            .get_item(3)?
89            .cast::<PyInt>()?
90            .extract::<QuantityRaw>()?;
91        let size_prec = py_tuple.get_item(4)?.cast::<PyInt>()?.extract::<u8>()?;
92
93        let aggressor_side_u8 = py_tuple.get_item(5)?.cast::<PyInt>()?.extract::<u8>()?;
94        let binding = py_tuple.get_item(6)?;
95        let trade_id_str = binding.cast::<PyString>()?.extract::<&str>()?;
96        let ts_event = py_tuple.get_item(7)?.cast::<PyInt>()?.extract::<u64>()?;
97        let ts_init = py_tuple.get_item(8)?.cast::<PyInt>()?.extract::<u64>()?;
98
99        self.instrument_id = InstrumentId::from_str(instrument_id_str).map_err(to_pyvalue_err)?;
100        self.price = Price::from_raw(price_raw, price_prec);
101        self.size = Quantity::from_raw(size_raw, size_prec);
102        self.aggressor_side = AggressorSide::from_u8(aggressor_side_u8).ok_or_else(|| {
103            to_pyvalue_err(format!("Invalid aggressor_side value: {aggressor_side_u8}"))
104        })?;
105        self.trade_id = TradeId::from(trade_id_str);
106        self.ts_event = ts_event.into();
107        self.ts_init = ts_init.into();
108
109        Ok(())
110    }
111
112    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
113        (
114            self.instrument_id.to_string(),
115            self.price.raw,
116            self.price.precision,
117            self.size.raw,
118            self.size.precision,
119            self.aggressor_side as u8,
120            self.trade_id.to_string(),
121            self.ts_event.as_u64(),
122            self.ts_init.as_u64(),
123        )
124            .into_py_any(py)
125    }
126
127    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
128        let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
129        let state = self.__getstate__(py)?;
130        (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
131    }
132
133    #[staticmethod]
134    fn _safe_constructor() -> Self {
135        Self::new(
136            InstrumentId::from("NULL.NULL"),
137            Price::zero(0),
138            Quantity::from(1), // size cannot be zero
139            AggressorSide::NoAggressor,
140            TradeId::from("NULL"),
141            UnixNanos::default(),
142            UnixNanos::default(),
143        )
144    }
145
146    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
147        match op {
148            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
149            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
150            _ => py.NotImplemented(),
151        }
152    }
153
154    fn __hash__(&self) -> isize {
155        let mut h = DefaultHasher::new();
156        self.hash(&mut h);
157        h.finish() as isize
158    }
159
160    fn __repr__(&self) -> String {
161        format!("{}({})", stringify!(TradeTick), self)
162    }
163
164    fn __str__(&self) -> String {
165        self.to_string()
166    }
167
168    #[getter]
169    #[pyo3(name = "instrument_id")]
170    fn py_instrument_id(&self) -> InstrumentId {
171        self.instrument_id
172    }
173
174    #[getter]
175    #[pyo3(name = "price")]
176    fn py_price(&self) -> Price {
177        self.price
178    }
179
180    #[getter]
181    #[pyo3(name = "size")]
182    fn py_size(&self) -> Quantity {
183        self.size
184    }
185
186    #[getter]
187    #[pyo3(name = "aggressor_side")]
188    fn py_aggressor_side(&self) -> AggressorSide {
189        self.aggressor_side
190    }
191
192    #[getter]
193    #[pyo3(name = "trade_id")]
194    fn py_trade_id(&self) -> TradeId {
195        self.trade_id
196    }
197
198    #[getter]
199    #[pyo3(name = "ts_event")]
200    fn py_ts_event(&self) -> u64 {
201        self.ts_event.as_u64()
202    }
203
204    #[getter]
205    #[pyo3(name = "ts_init")]
206    fn py_ts_init(&self) -> u64 {
207        self.ts_init.as_u64()
208    }
209
210    #[staticmethod]
211    #[pyo3(name = "fully_qualified_name")]
212    fn py_fully_qualified_name() -> String {
213        format!("{}:{}", PY_MODULE_MODEL, stringify!(TradeTick))
214    }
215
216    /// Returns the metadata for the type, for use with serialization formats.
217    #[staticmethod]
218    #[pyo3(name = "get_metadata")]
219    fn py_get_metadata(
220        instrument_id: &InstrumentId,
221        price_precision: u8,
222        size_precision: u8,
223    ) -> HashMap<String, String> {
224        Self::get_metadata(instrument_id, price_precision, size_precision)
225    }
226
227    /// Returns the field map for the type, for use with Arrow schemas.
228    #[staticmethod]
229    #[pyo3(name = "get_fields")]
230    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
231        let py_dict = PyDict::new(py);
232        for (k, v) in Self::get_fields() {
233            py_dict.set_item(k, v)?;
234        }
235
236        Ok(py_dict)
237    }
238
239    #[staticmethod]
240    #[pyo3(name = "from_raw")]
241    #[expect(clippy::too_many_arguments)]
242    fn py_from_raw(
243        instrument_id: InstrumentId,
244        price_raw: PriceRaw,
245        price_prec: u8,
246        size_raw: QuantityRaw,
247        size_prec: u8,
248        aggressor_side: AggressorSide,
249        trade_id: TradeId,
250        ts_event: u64,
251        ts_init: u64,
252    ) -> PyResult<Self> {
253        Self::new_checked(
254            instrument_id,
255            Price::from_raw(price_raw, price_prec),
256            Quantity::from_raw(size_raw, size_prec),
257            aggressor_side,
258            trade_id,
259            ts_event.into(),
260            ts_init.into(),
261        )
262        .map_err(to_pyvalue_err)
263    }
264
265    /// Returns a new object from the given dictionary representation.
266    #[staticmethod]
267    #[pyo3(name = "from_dict")]
268    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
269        from_dict_pyo3(py, values)
270    }
271
272    /// Return a dictionary representation of the object.
273    #[pyo3(name = "to_dict")]
274    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
275        to_dict_pyo3(py, self)
276    }
277
278    /// Return JSON encoded bytes representation of the object.
279    #[pyo3(name = "to_json_bytes")]
280    fn py_to_json_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
281        self.to_json_bytes()
282            .map_err(to_pyvalue_err)?
283            .into_py_any(py)
284    }
285
286    /// Return `MsgPack` encoded bytes representation of the object.
287    #[pyo3(name = "to_msgpack_bytes")]
288    fn py_to_msgpack_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
289        self.to_msgpack_bytes()
290            .map_err(to_pyvalue_err)?
291            .into_py_any(py)
292    }
293}
294
295#[pymethods]
296impl TradeTick {
297    #[staticmethod]
298    #[pyo3(name = "from_json")]
299    fn py_from_json(data: &[u8]) -> PyResult<Self> {
300        Self::from_json_bytes(data).map_err(to_pyvalue_err)
301    }
302
303    #[staticmethod]
304    #[pyo3(name = "from_msgpack")]
305    fn py_from_msgpack(data: &[u8]) -> PyResult<Self> {
306        Self::from_msgpack_bytes(data).map_err(to_pyvalue_err)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use pyo3::Python;
313    use rstest::rstest;
314
315    use crate::{
316        data::{TradeTick, stubs::stub_trade_ethusdt_buy},
317        enums::AggressorSide,
318        identifiers::{InstrumentId, TradeId},
319        types::{Price, Quantity},
320    };
321
322    #[rstest]
323    fn test_trade_tick_py_new_with_zero_size() {
324        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
325        let price = Price::from("10000.00");
326        let zero_size = Quantity::from(0);
327        let aggressor_side = AggressorSide::Buy;
328        let trade_id = TradeId::from("123456789");
329        let ts_event = 1;
330        let ts_init = 2;
331
332        let result = TradeTick::py_new(
333            instrument_id,
334            price,
335            zero_size,
336            aggressor_side,
337            trade_id,
338            ts_event,
339            ts_init,
340        );
341
342        assert!(result.is_err());
343    }
344
345    #[rstest]
346    fn test_to_dict(stub_trade_ethusdt_buy: TradeTick) {
347        let trade = stub_trade_ethusdt_buy;
348
349        Python::initialize();
350        Python::attach(|py| {
351            let dict_string = trade.py_to_dict(py).unwrap().to_string();
352            let expected_string = "{'type': 'TradeTick', 'instrument_id': 'ETHUSDT-PERP.BINANCE', 'price': '10000.0000', 'size': '1.00000000', 'aggressor_side': 'BUY', 'trade_id': '123456789', 'ts_event': 0, 'ts_init': 1}";
353            assert_eq!(dict_string, expected_string);
354        });
355    }
356
357    #[rstest]
358    fn test_from_dict(stub_trade_ethusdt_buy: TradeTick) {
359        let trade = stub_trade_ethusdt_buy;
360
361        Python::initialize();
362        Python::attach(|py| {
363            let dict = trade.py_to_dict(py).unwrap();
364            let parsed = TradeTick::py_from_dict(py, dict).unwrap();
365            assert_eq!(parsed, trade);
366        });
367    }
368}