Skip to main content

nautilus_model/python/data/
quote.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::QuoteTick,
43    enums::PriceType,
44    identifiers::InstrumentId,
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 QuoteTick {
55    /// Represents a quote tick in a market.
56    #[new]
57    fn py_new(
58        instrument_id: InstrumentId,
59        bid_price: Price,
60        ask_price: Price,
61        bid_size: Quantity,
62        ask_size: Quantity,
63        ts_event: u64,
64        ts_init: u64,
65    ) -> PyResult<Self> {
66        Self::new_checked(
67            instrument_id,
68            bid_price,
69            ask_price,
70            bid_size,
71            ask_size,
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: &str = binding.cast::<PyString>()?.extract()?;
82        let bid_price_raw: PriceRaw = py_tuple.get_item(1)?.cast::<PyInt>()?.extract()?;
83        let ask_price_raw: PriceRaw = py_tuple.get_item(2)?.cast::<PyInt>()?.extract()?;
84        let bid_price_prec: u8 = py_tuple.get_item(3)?.cast::<PyInt>()?.extract()?;
85        let ask_price_prec: u8 = py_tuple.get_item(4)?.cast::<PyInt>()?.extract()?;
86
87        let bid_size_raw: QuantityRaw = py_tuple.get_item(5)?.cast::<PyInt>()?.extract()?;
88        let ask_size_raw: QuantityRaw = py_tuple.get_item(6)?.cast::<PyInt>()?.extract()?;
89        let bid_size_prec: u8 = py_tuple.get_item(7)?.cast::<PyInt>()?.extract()?;
90        let ask_size_prec: u8 = py_tuple.get_item(8)?.cast::<PyInt>()?.extract()?;
91        let ts_event: u64 = py_tuple.get_item(9)?.cast::<PyInt>()?.extract()?;
92        let ts_init: u64 = py_tuple.get_item(10)?.cast::<PyInt>()?.extract()?;
93
94        let instrument_id = InstrumentId::from_str(instrument_id_str).map_err(to_pyvalue_err)?;
95        let bid_price = Price::from_raw_checked(bid_price_raw, bid_price_prec)
96            .map_err(correctness_error_to_pyvalue_err)?;
97        let ask_price = Price::from_raw_checked(ask_price_raw, ask_price_prec)
98            .map_err(correctness_error_to_pyvalue_err)?;
99        let bid_size = Quantity::from_raw_checked(bid_size_raw, bid_size_prec)
100            .map_err(correctness_error_to_pyvalue_err)?;
101        let ask_size = Quantity::from_raw_checked(ask_size_raw, ask_size_prec)
102            .map_err(correctness_error_to_pyvalue_err)?;
103
104        *self = Self {
105            instrument_id,
106            bid_price,
107            ask_price,
108            bid_size,
109            ask_size,
110            ts_event: ts_event.into(),
111            ts_init: ts_init.into(),
112        };
113
114        Ok(())
115    }
116
117    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
118        (
119            self.instrument_id.to_string(),
120            self.bid_price.raw(),
121            self.ask_price.raw(),
122            self.bid_price.precision,
123            self.ask_price.precision,
124            self.bid_size.raw(),
125            self.ask_size.raw(),
126            self.bid_size.precision,
127            self.ask_size.precision,
128            self.ts_event.as_u64(),
129            self.ts_init.as_u64(),
130        )
131            .into_py_any(py)
132    }
133
134    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
135        let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
136        let state = self.__getstate__(py)?;
137        (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
138    }
139
140    #[staticmethod]
141    fn _safe_constructor() -> PyResult<Self> {
142        Self::new_checked(
143            InstrumentId::from("NULL.NULL"),
144            Price::zero(0),
145            Price::zero(0),
146            Quantity::zero(0),
147            Quantity::zero(0),
148            UnixNanos::default(),
149            UnixNanos::default(),
150        )
151        .map_err(to_pyvalue_err)
152    }
153
154    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
155        match op {
156            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
157            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
158            _ => py.NotImplemented(),
159        }
160    }
161
162    fn __hash__(&self) -> isize {
163        let mut h = DefaultHasher::new();
164        self.hash(&mut h);
165        h.finish() as isize
166    }
167
168    fn __repr__(&self) -> String {
169        format!("{}({})", stringify!(QuoteTick), self)
170    }
171
172    fn __str__(&self) -> String {
173        self.to_string()
174    }
175
176    #[getter]
177    #[pyo3(name = "instrument_id")]
178    fn py_instrument_id(&self) -> InstrumentId {
179        self.instrument_id
180    }
181
182    #[getter]
183    #[pyo3(name = "bid_price")]
184    fn py_bid_price(&self) -> Price {
185        self.bid_price
186    }
187
188    #[getter]
189    #[pyo3(name = "ask_price")]
190    fn py_ask_price(&self) -> Price {
191        self.ask_price
192    }
193
194    #[getter]
195    #[pyo3(name = "bid_size")]
196    fn py_bid_size(&self) -> Quantity {
197        self.bid_size
198    }
199
200    #[getter]
201    #[pyo3(name = "ask_size")]
202    fn py_ask_size(&self) -> Quantity {
203        self.ask_size
204    }
205
206    #[getter]
207    #[pyo3(name = "ts_event")]
208    fn py_ts_event(&self) -> u64 {
209        self.ts_event.as_u64()
210    }
211
212    #[getter]
213    #[pyo3(name = "ts_init")]
214    fn py_ts_init(&self) -> u64 {
215        self.ts_init.as_u64()
216    }
217
218    #[staticmethod]
219    #[pyo3(name = "fully_qualified_name")]
220    fn py_fully_qualified_name() -> String {
221        format!("{}:{}", PY_MODULE_MODEL, stringify!(QuoteTick))
222    }
223
224    /// Returns the metadata for the type, for use with serialization formats.
225    #[staticmethod]
226    #[pyo3(name = "get_metadata")]
227    fn py_get_metadata(
228        instrument_id: &InstrumentId,
229        price_precision: u8,
230        size_precision: u8,
231    ) -> HashMap<String, String> {
232        Self::get_metadata(instrument_id, price_precision, size_precision)
233    }
234
235    /// Returns the field map for the type, for use with Arrow schemas.
236    #[staticmethod]
237    #[pyo3(name = "get_fields")]
238    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
239        let py_dict = PyDict::new(py);
240        for (k, v) in Self::get_fields() {
241            py_dict.set_item(k, v)?;
242        }
243
244        Ok(py_dict)
245    }
246
247    #[staticmethod]
248    #[pyo3(name = "from_raw")]
249    #[expect(clippy::too_many_arguments)]
250    fn py_from_raw(
251        instrument_id: InstrumentId,
252        bid_price_raw: PriceRaw,
253        ask_price_raw: PriceRaw,
254        bid_price_prec: u8,
255        ask_price_prec: u8,
256        bid_size_raw: QuantityRaw,
257        ask_size_raw: QuantityRaw,
258        bid_size_prec: u8,
259        ask_size_prec: u8,
260        ts_event: u64,
261        ts_init: u64,
262    ) -> PyResult<Self> {
263        let bid_price = Price::from_raw_checked(bid_price_raw, bid_price_prec)
264            .map_err(correctness_error_to_pyvalue_err)?;
265        let ask_price = Price::from_raw_checked(ask_price_raw, ask_price_prec)
266            .map_err(correctness_error_to_pyvalue_err)?;
267        let bid_size = Quantity::from_raw_checked(bid_size_raw, bid_size_prec)
268            .map_err(correctness_error_to_pyvalue_err)?;
269        let ask_size = Quantity::from_raw_checked(ask_size_raw, ask_size_prec)
270            .map_err(correctness_error_to_pyvalue_err)?;
271
272        Self::new_checked(
273            instrument_id,
274            bid_price,
275            ask_price,
276            bid_size,
277            ask_size,
278            ts_event.into(),
279            ts_init.into(),
280        )
281        .map_err(to_pyvalue_err)
282    }
283
284    /// Returns a new object from the given dictionary representation.
285    #[staticmethod]
286    #[pyo3(name = "from_dict")]
287    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
288        from_dict_pyo3(py, values)
289    }
290
291    /// Returns the `Price` for this quote depending on the given `price_type`.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` price).
296    #[pyo3(name = "extract_price")]
297    fn py_extract_price(&self, price_type: PriceType) -> PyResult<Price> {
298        self.extract_price(price_type).map_err(to_pyvalue_err)
299    }
300
301    /// Returns the `Quantity` for this quote depending on the given `price_type`.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if `price_type` is not `Bid`, `Ask`, or `Mid` (a quote has no `Last` size).
306    #[pyo3(name = "extract_size")]
307    fn py_extract_size(&self, price_type: PriceType) -> PyResult<Quantity> {
308        self.extract_size(price_type).map_err(to_pyvalue_err)
309    }
310
311    /// Return a dictionary representation of the object.
312    #[pyo3(name = "to_dict")]
313    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
314        to_dict_pyo3(py, self)
315    }
316
317    /// Return JSON encoded bytes representation of the object.
318    #[pyo3(name = "to_json_bytes")]
319    fn py_to_json_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
320        self.to_json_bytes()
321            .map_err(to_pyvalue_err)?
322            .into_py_any(py)
323    }
324
325    /// Return `MsgPack` encoded bytes representation of the object.
326    #[pyo3(name = "to_msgpack_bytes")]
327    fn py_to_msgpack_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
328        self.to_msgpack_bytes()
329            .map_err(to_pyvalue_err)?
330            .into_py_any(py)
331    }
332}
333
334#[pymethods]
335impl QuoteTick {
336    #[staticmethod]
337    #[pyo3(name = "from_json")]
338    fn py_from_json(data: &[u8]) -> PyResult<Self> {
339        Self::from_json_bytes(data).map_err(to_pyvalue_err)
340    }
341
342    #[staticmethod]
343    #[pyo3(name = "from_msgpack")]
344    fn py_from_msgpack(data: &[u8]) -> PyResult<Self> {
345        Self::from_msgpack_bytes(data).map_err(to_pyvalue_err)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use pyo3::Python;
352    use rstest::rstest;
353
354    use crate::{
355        data::{QuoteTick, stubs::quote_ethusdt_binance},
356        identifiers::InstrumentId,
357        types::{Price, Quantity, fixed::check_fixed_precision},
358    };
359
360    #[rstest]
361    #[case(
362    Price::new(0.010_000, 6),
363    Price::new(0.010_001_0, 7), // Mismatched precision
364    Quantity::new(0.001_000, 6),
365    Quantity::new(0.001_000, 6),
366)]
367    #[case(
368    Price::new(0.010_000, 6),
369    Price::new(0.010_001, 6),
370    Quantity::new(0.001_000, 6),
371    Quantity::new(0.001_000_0, 7), // Mismatched precision
372)]
373    fn test_quote_tick_py_new_invalid_precisions(
374        #[case] bid_price: Price,
375        #[case] ask_price: Price,
376        #[case] bid_size: Quantity,
377        #[case] ask_size: Quantity,
378    ) {
379        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
380        let ts_event = 0;
381        let ts_init = 1;
382
383        let result = QuoteTick::py_new(
384            instrument_id,
385            bid_price,
386            ask_price,
387            bid_size,
388            ask_size,
389            ts_event,
390            ts_init,
391        );
392
393        assert!(result.is_err());
394    }
395
396    #[rstest]
397    fn test_py_from_raw_rejects_invalid_precision(quote_ethusdt_binance: QuoteTick) {
398        let quote = quote_ethusdt_binance;
399        let expected = check_fixed_precision(u8::MAX).unwrap_err();
400
401        Python::initialize();
402        Python::attach(|_| {
403            let error = QuoteTick::py_from_raw(
404                quote.instrument_id,
405                quote.bid_price.raw,
406                quote.ask_price.raw,
407                quote.bid_price.precision,
408                quote.ask_price.precision,
409                quote.bid_size.raw,
410                quote.ask_size.raw,
411                u8::MAX,
412                quote.ask_size.precision,
413                quote.ts_event.as_u64(),
414                quote.ts_init.as_u64(),
415            )
416            .unwrap_err();
417
418            assert_eq!(error.to_string(), format!("ValueError: {expected}"));
419        });
420    }
421
422    #[rstest]
423    fn test_to_dict(quote_ethusdt_binance: QuoteTick) {
424        let quote = quote_ethusdt_binance;
425
426        Python::initialize();
427        Python::attach(|py| {
428            let dict_string = quote.py_to_dict(py).unwrap().to_string();
429            let expected_string = "{'type': 'QuoteTick', 'instrument_id': 'ETHUSDT-PERP.BINANCE', 'bid_price': '10000.0000', 'ask_price': '10001.0000', 'bid_size': '1.00000000', 'ask_size': '1.00000000', 'ts_event': 0, 'ts_init': 1}";
430            assert_eq!(dict_string, expected_string);
431        });
432    }
433
434    #[rstest]
435    fn test_from_dict(quote_ethusdt_binance: QuoteTick) {
436        let quote = quote_ethusdt_binance;
437
438        Python::initialize();
439        Python::attach(|py| {
440            let dict = quote.py_to_dict(py).unwrap();
441            let parsed = QuoteTick::py_from_dict(py, dict).unwrap();
442            assert_eq!(parsed, quote);
443        });
444    }
445}