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, correctness_error_to_pyvalue_err,
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        let instrument_id = InstrumentId::from_str(instrument_id_str).map_err(to_pyvalue_err)?;
100        let price = Price::from_raw_checked(price_raw, price_prec)
101            .map_err(correctness_error_to_pyvalue_err)?;
102        let size = Quantity::from_raw_checked(size_raw, size_prec)
103            .map_err(correctness_error_to_pyvalue_err)?;
104
105        let aggressor_side = AggressorSide::from_u8(aggressor_side_u8).ok_or_else(|| {
106            to_pyvalue_err(format!("Invalid aggressor_side value: {aggressor_side_u8}"))
107        })?;
108
109        let trade_id =
110            TradeId::new_checked(trade_id_str).map_err(correctness_error_to_pyvalue_err)?;
111
112        *self = Self {
113            instrument_id,
114            price,
115            size,
116            aggressor_side,
117            trade_id,
118            ts_event: ts_event.into(),
119            ts_init: ts_init.into(),
120        };
121
122        Ok(())
123    }
124
125    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
126        (
127            self.instrument_id.to_string(),
128            self.price.raw(),
129            self.price.precision,
130            self.size.raw(),
131            self.size.precision,
132            self.aggressor_side as u8,
133            self.trade_id.to_string(),
134            self.ts_event.as_u64(),
135            self.ts_init.as_u64(),
136        )
137            .into_py_any(py)
138    }
139
140    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
141        let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
142        let state = self.__getstate__(py)?;
143        (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
144    }
145
146    #[staticmethod]
147    fn _safe_constructor() -> Self {
148        Self::new(
149            InstrumentId::from("NULL.NULL"),
150            Price::zero(0),
151            Quantity::from(1), // size cannot be zero
152            AggressorSide::NoAggressor,
153            TradeId::from("NULL"),
154            UnixNanos::default(),
155            UnixNanos::default(),
156        )
157    }
158
159    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
160        match op {
161            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
162            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
163            _ => py.NotImplemented(),
164        }
165    }
166
167    fn __hash__(&self) -> isize {
168        let mut h = DefaultHasher::new();
169        self.hash(&mut h);
170        h.finish() as isize
171    }
172
173    fn __repr__(&self) -> String {
174        format!("{}({})", stringify!(TradeTick), self)
175    }
176
177    fn __str__(&self) -> String {
178        self.to_string()
179    }
180
181    #[getter]
182    #[pyo3(name = "instrument_id")]
183    fn py_instrument_id(&self) -> InstrumentId {
184        self.instrument_id
185    }
186
187    #[getter]
188    #[pyo3(name = "price")]
189    fn py_price(&self) -> Price {
190        self.price
191    }
192
193    #[getter]
194    #[pyo3(name = "size")]
195    fn py_size(&self) -> Quantity {
196        self.size
197    }
198
199    #[getter]
200    #[pyo3(name = "aggressor_side")]
201    fn py_aggressor_side(&self) -> AggressorSide {
202        self.aggressor_side
203    }
204
205    #[getter]
206    #[pyo3(name = "trade_id")]
207    fn py_trade_id(&self) -> TradeId {
208        self.trade_id
209    }
210
211    #[getter]
212    #[pyo3(name = "ts_event")]
213    fn py_ts_event(&self) -> u64 {
214        self.ts_event.as_u64()
215    }
216
217    #[getter]
218    #[pyo3(name = "ts_init")]
219    fn py_ts_init(&self) -> u64 {
220        self.ts_init.as_u64()
221    }
222
223    #[staticmethod]
224    #[pyo3(name = "fully_qualified_name")]
225    fn py_fully_qualified_name() -> String {
226        format!("{}:{}", PY_MODULE_MODEL, stringify!(TradeTick))
227    }
228
229    /// Returns the metadata for the type, for use with serialization formats.
230    #[staticmethod]
231    #[pyo3(name = "get_metadata")]
232    fn py_get_metadata(
233        instrument_id: &InstrumentId,
234        price_precision: u8,
235        size_precision: u8,
236    ) -> HashMap<String, String> {
237        Self::get_metadata(instrument_id, price_precision, size_precision)
238    }
239
240    /// Returns the field map for the type, for use with Arrow schemas.
241    #[staticmethod]
242    #[pyo3(name = "get_fields")]
243    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
244        let py_dict = PyDict::new(py);
245        for (k, v) in Self::get_fields() {
246            py_dict.set_item(k, v)?;
247        }
248
249        Ok(py_dict)
250    }
251
252    #[staticmethod]
253    #[pyo3(name = "from_raw")]
254    #[expect(clippy::too_many_arguments)]
255    fn py_from_raw(
256        instrument_id: InstrumentId,
257        price_raw: PriceRaw,
258        price_prec: u8,
259        size_raw: QuantityRaw,
260        size_prec: u8,
261        aggressor_side: AggressorSide,
262        trade_id: TradeId,
263        ts_event: u64,
264        ts_init: u64,
265    ) -> PyResult<Self> {
266        let price = Price::from_raw_checked(price_raw, price_prec)
267            .map_err(correctness_error_to_pyvalue_err)?;
268        let size = Quantity::from_raw_checked(size_raw, size_prec)
269            .map_err(correctness_error_to_pyvalue_err)?;
270
271        Self::new_checked(
272            instrument_id,
273            price,
274            size,
275            aggressor_side,
276            trade_id,
277            ts_event.into(),
278            ts_init.into(),
279        )
280        .map_err(to_pyvalue_err)
281    }
282
283    /// Returns a new object from the given dictionary representation.
284    #[staticmethod]
285    #[pyo3(name = "from_dict")]
286    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
287        from_dict_pyo3(py, values)
288    }
289
290    /// Return a dictionary representation of the object.
291    #[pyo3(name = "to_dict")]
292    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
293        to_dict_pyo3(py, self)
294    }
295
296    /// Return JSON encoded bytes representation of the object.
297    #[pyo3(name = "to_json_bytes")]
298    fn py_to_json_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
299        self.to_json_bytes()
300            .map_err(to_pyvalue_err)?
301            .into_py_any(py)
302    }
303
304    /// Return `MsgPack` encoded bytes representation of the object.
305    #[pyo3(name = "to_msgpack_bytes")]
306    fn py_to_msgpack_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
307        self.to_msgpack_bytes()
308            .map_err(to_pyvalue_err)?
309            .into_py_any(py)
310    }
311}
312
313#[pymethods]
314impl TradeTick {
315    #[staticmethod]
316    #[pyo3(name = "from_json")]
317    fn py_from_json(data: &[u8]) -> PyResult<Self> {
318        Self::from_json_bytes(data).map_err(to_pyvalue_err)
319    }
320
321    #[staticmethod]
322    #[pyo3(name = "from_msgpack")]
323    fn py_from_msgpack(data: &[u8]) -> PyResult<Self> {
324        Self::from_msgpack_bytes(data).map_err(to_pyvalue_err)
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use pyo3::Python;
331    use rstest::rstest;
332
333    use crate::{
334        data::{TradeTick, stubs::stub_trade_ethusdt_buy},
335        enums::AggressorSide,
336        identifiers::{InstrumentId, TradeId},
337        types::{
338            Price, Quantity,
339            price::{PRICE_RAW_MAX, PRICE_RAW_MIN},
340        },
341    };
342
343    #[rstest]
344    fn test_trade_tick_py_new_with_zero_size() {
345        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
346        let price = Price::from("10000.00");
347        let zero_size = Quantity::from(0);
348        let aggressor_side = AggressorSide::Buy;
349        let trade_id = TradeId::from("123456789");
350        let ts_event = 1;
351        let ts_init = 2;
352
353        let result = TradeTick::py_new(
354            instrument_id,
355            price,
356            zero_size,
357            aggressor_side,
358            trade_id,
359            ts_event,
360            ts_init,
361        );
362
363        assert!(result.is_err());
364    }
365
366    #[rstest]
367    fn test_py_from_raw_rejects_out_of_range_price(stub_trade_ethusdt_buy: TradeTick) {
368        let trade = stub_trade_ethusdt_buy;
369        let raw = PRICE_RAW_MAX.saturating_add(1);
370
371        Python::initialize();
372        Python::attach(|_| {
373            let error = TradeTick::py_from_raw(
374                trade.instrument_id,
375                raw,
376                trade.price.precision,
377                trade.size.raw,
378                trade.size.precision,
379                trade.aggressor_side,
380                trade.trade_id,
381                trade.ts_event.as_u64(),
382                trade.ts_init.as_u64(),
383            )
384            .unwrap_err();
385
386            assert_eq!(
387                error.to_string(),
388                format!(
389                    "ValueError: raw value {raw} outside valid range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}]"
390                )
391            );
392        });
393    }
394
395    #[rstest]
396    fn test_to_dict(stub_trade_ethusdt_buy: TradeTick) {
397        let trade = stub_trade_ethusdt_buy;
398
399        Python::initialize();
400        Python::attach(|py| {
401            let dict_string = trade.py_to_dict(py).unwrap().to_string();
402            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}";
403            assert_eq!(dict_string, expected_string);
404        });
405    }
406
407    #[rstest]
408    fn test_from_dict(stub_trade_ethusdt_buy: TradeTick) {
409        let trade = stub_trade_ethusdt_buy;
410
411        Python::initialize();
412        Python::attach(|py| {
413            let dict = trade.py_to_dict(py).unwrap();
414            let parsed = TradeTick::py_from_dict(py, dict).unwrap();
415            assert_eq!(parsed, trade);
416        });
417    }
418}