1use 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 #[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 #[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 #[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 #[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 #[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 #[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 #[pyo3(name = "to_dict")]
313 fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
314 to_dict_pyo3(py, self)
315 }
316
317 #[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 #[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), 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), )]
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}