Skip to main content

nautilus_model/python/instruments/
betting.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::hash_map::DefaultHasher,
18    hash::{Hash, Hasher},
19};
20
21use nautilus_core::{
22    from_pydict,
23    python::{IntoPyObjectNautilusExt, to_pyvalue_err},
24};
25use pyo3::{basic::CompareOp, prelude::*, types::PyDict};
26use rust_decimal::Decimal;
27use ustr::Ustr;
28
29use crate::{
30    enums::{AssetClass, InstrumentClass},
31    identifiers::{InstrumentId, Symbol},
32    instruments::BettingInstrument,
33    types::{Currency, Money, Price, Quantity},
34};
35
36#[pymethods]
37#[pyo3_stub_gen::derive::gen_stub_pymethods]
38impl BettingInstrument {
39    /// Represents a betting instrument with complete market and selection details.
40    #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
41    #[new]
42    #[pyo3(signature = (instrument_id, raw_symbol, event_type_id, event_type_name, competition_id, competition_name, event_id, event_name, event_country_code, event_open_date, betting_type, market_id, market_name, market_type, market_start_time, selection_id, selection_name, selection_handicap, currency, price_precision, size_precision, price_increment, size_increment, ts_event, ts_init, max_quantity=None, min_quantity=None, max_notional=None, min_notional=None, max_price=None, min_price=None, margin_init=None, margin_maint=None, tick_scheme=None, info=None))]
43    fn py_new(
44        instrument_id: InstrumentId,
45        raw_symbol: Symbol,
46        event_type_id: u64,
47        event_type_name: String,
48        competition_id: u64,
49        competition_name: String,
50        event_id: u64,
51        event_name: String,
52        event_country_code: String,
53        event_open_date: u64,
54        betting_type: String,
55        market_id: String,
56        market_name: String,
57        market_type: String,
58        market_start_time: u64,
59        selection_id: u64,
60        selection_name: String,
61        selection_handicap: f64,
62        currency: Currency,
63        price_precision: u8,
64        size_precision: u8,
65        price_increment: Price,
66        size_increment: Quantity,
67        ts_event: u64,
68        ts_init: u64,
69        max_quantity: Option<Quantity>,
70        min_quantity: Option<Quantity>,
71        max_notional: Option<Money>,
72        min_notional: Option<Money>,
73        max_price: Option<Price>,
74        min_price: Option<Price>,
75        margin_init: Option<Decimal>,
76        margin_maint: Option<Decimal>,
77        tick_scheme: Option<String>,
78        info: Option<Py<PyDict>>,
79    ) -> PyResult<Self> {
80        // Convert Python dict to Params
81        let info_map = if let Some(info_dict) = info {
82            Python::attach(|py| from_pydict(py, &info_dict))?
83        } else {
84            None
85        };
86
87        Self::builder()
88            .instrument_id(instrument_id)
89            .raw_symbol(raw_symbol)
90            .event_type_id(event_type_id)
91            .event_type_name(Ustr::from(&event_type_name))
92            .competition_id(competition_id)
93            .competition_name(Ustr::from(&competition_name))
94            .event_id(event_id)
95            .event_name(Ustr::from(&event_name))
96            .event_country_code(Ustr::from(&event_country_code))
97            .event_open_date(event_open_date.into())
98            .betting_type(Ustr::from(&betting_type))
99            .market_id(Ustr::from(&market_id))
100            .market_name(Ustr::from(&market_name))
101            .market_type(Ustr::from(&market_type))
102            .market_start_time(market_start_time.into())
103            .selection_id(selection_id)
104            .selection_name(Ustr::from(&selection_name))
105            .selection_handicap(selection_handicap)
106            .currency(currency)
107            .price_precision(price_precision)
108            .size_precision(size_precision)
109            .price_increment(price_increment)
110            .size_increment(size_increment)
111            .maybe_max_quantity(max_quantity)
112            .maybe_min_quantity(min_quantity)
113            .maybe_max_notional(max_notional)
114            .maybe_min_notional(min_notional)
115            .maybe_max_price(max_price)
116            .maybe_min_price(min_price)
117            .maybe_margin_init(margin_init)
118            .maybe_margin_maint(margin_maint)
119            .maybe_tick_scheme(tick_scheme.map(|name| ustr::Ustr::from(name.as_str())))
120            .maybe_info(info_map)
121            .ts_event(ts_event.into())
122            .ts_init(ts_init.into())
123            .build()
124            .map_err(to_pyvalue_err)
125    }
126
127    fn __hash__(&self) -> isize {
128        let mut hasher = DefaultHasher::new();
129        self.hash(&mut hasher);
130        hasher.finish() as isize
131    }
132
133    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
134        match op {
135            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
136            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
137            _ => py.NotImplemented(),
138        }
139    }
140
141    #[getter]
142    fn type_name(&self) -> &'static str {
143        stringify!(BettingInstrument)
144    }
145
146    #[getter]
147    #[pyo3(name = "id")]
148    fn py_id(&self) -> InstrumentId {
149        self.id
150    }
151
152    #[getter]
153    #[pyo3(name = "raw_symbol")]
154    fn py_raw_symbol(&self) -> Symbol {
155        self.raw_symbol
156    }
157
158    #[getter]
159    #[pyo3(name = "asset_class")]
160    fn py_asset_class(&self) -> AssetClass {
161        AssetClass::Alternative
162    }
163
164    #[getter]
165    #[pyo3(name = "instrument_class")]
166    fn py_instrument_class(&self) -> InstrumentClass {
167        InstrumentClass::SportsBetting
168    }
169
170    #[getter]
171    #[pyo3(name = "event_type_id")]
172    fn py_event_type_id(&self) -> u64 {
173        self.event_type_id
174    }
175
176    #[getter]
177    #[pyo3(name = "event_type_name")]
178    fn py_event_type_name(&self) -> &str {
179        self.event_type_name.as_str()
180    }
181
182    #[getter]
183    #[pyo3(name = "competition_id")]
184    fn py_competition_id(&self) -> u64 {
185        self.competition_id
186    }
187
188    #[getter]
189    #[pyo3(name = "competition_name")]
190    fn py_competition_name(&self) -> &str {
191        self.competition_name.as_str()
192    }
193
194    #[getter]
195    #[pyo3(name = "event_id")]
196    fn py_event_id(&self) -> u64 {
197        self.event_id
198    }
199
200    #[getter]
201    #[pyo3(name = "event_name")]
202    fn py_event_name(&self) -> &str {
203        self.event_name.as_str()
204    }
205
206    #[getter]
207    #[pyo3(name = "event_country_code")]
208    fn py_event_country_code(&self) -> &str {
209        self.event_country_code.as_str()
210    }
211
212    #[getter]
213    #[pyo3(name = "event_open_date")]
214    fn py_event_open_date(&self) -> u64 {
215        self.event_open_date.as_u64()
216    }
217
218    #[getter]
219    #[pyo3(name = "betting_type")]
220    fn py_betting_type(&self) -> &str {
221        self.betting_type.as_str()
222    }
223
224    #[getter]
225    #[pyo3(name = "market_id")]
226    fn py_market_id(&self) -> &str {
227        self.market_id.as_str()
228    }
229
230    #[getter]
231    #[pyo3(name = "market_name")]
232    fn py_market_name(&self) -> &str {
233        self.market_name.as_str()
234    }
235
236    #[getter]
237    #[pyo3(name = "market_type")]
238    fn py_market_type(&self) -> &str {
239        self.market_type.as_str()
240    }
241
242    #[getter]
243    #[pyo3(name = "market_start_time")]
244    fn py_market_start_time(&self) -> u64 {
245        self.market_start_time.as_u64()
246    }
247
248    #[getter]
249    #[pyo3(name = "selection_id")]
250    fn py_selection_id(&self) -> u64 {
251        self.selection_id
252    }
253
254    #[getter]
255    #[pyo3(name = "selection_name")]
256    fn py_selection_name(&self) -> &str {
257        self.selection_name.as_str()
258    }
259
260    #[getter]
261    #[pyo3(name = "selection_handicap")]
262    fn py_selection_handicap(&self) -> f64 {
263        self.selection_handicap
264    }
265
266    #[getter]
267    #[pyo3(name = "currency")]
268    fn py_currency(&self) -> Currency {
269        self.currency
270    }
271
272    #[getter]
273    #[pyo3(name = "price_precision")]
274    fn py_price_precision(&self) -> u8 {
275        self.price_precision
276    }
277
278    #[getter]
279    #[pyo3(name = "size_precision")]
280    fn py_size_precision(&self) -> u8 {
281        self.size_precision
282    }
283
284    #[getter]
285    #[pyo3(name = "price_increment")]
286    fn py_price_increment(&self) -> Price {
287        self.price_increment
288    }
289
290    #[getter]
291    #[pyo3(name = "size_increment")]
292    fn py_size_increment(&self) -> Quantity {
293        self.size_increment
294    }
295
296    #[getter]
297    #[pyo3(name = "max_quantity")]
298    fn py_max_quantity(&self) -> Option<Quantity> {
299        self.max_quantity
300    }
301
302    #[getter]
303    #[pyo3(name = "min_quantity")]
304    fn py_min_quantity(&self) -> Option<Quantity> {
305        self.min_quantity
306    }
307
308    #[getter]
309    #[pyo3(name = "max_notional")]
310    fn py_max_notional(&self) -> Option<Money> {
311        self.max_notional
312    }
313
314    #[getter]
315    #[pyo3(name = "min_notional")]
316    fn py_min_notional(&self) -> Option<Money> {
317        self.min_notional
318    }
319
320    #[getter]
321    #[pyo3(name = "max_price")]
322    fn py_max_price(&self) -> Option<Price> {
323        self.max_price
324    }
325
326    #[getter]
327    #[pyo3(name = "min_price")]
328    fn py_min_price(&self) -> Option<Price> {
329        self.min_price
330    }
331
332    #[getter]
333    #[pyo3(name = "info")]
334    fn py_info(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
335        // Convert HashMap<String, serde_json::Value> back to Python dict
336        if let Some(ref info_map) = self.info {
337            let py_dict = PyDict::new(py);
338
339            for (key, value) in info_map {
340                // Convert serde_json::Value back to Python object via JSON
341                let json_str = serde_json::to_string(value).map_err(to_pyvalue_err)?;
342                let py_value =
343                    PyModule::import(py, "json")?.call_method("loads", (json_str,), None)?;
344                py_dict.set_item(key, py_value)?;
345            }
346            Ok(py_dict.unbind())
347        } else {
348            Ok(PyDict::new(py).unbind())
349        }
350    }
351
352    #[getter]
353    #[pyo3(name = "ts_event")]
354    fn py_ts_event(&self) -> u64 {
355        self.ts_event.as_u64()
356    }
357
358    #[getter]
359    #[pyo3(name = "ts_init")]
360    fn py_ts_init(&self) -> u64 {
361        self.ts_init.as_u64()
362    }
363
364    #[staticmethod]
365    #[pyo3(name = "from_dict")]
366    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
367        crate::python::instruments::from_dict_instrument_pyo3(py, values)
368    }
369
370    #[pyo3(name = "to_dict")]
371    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
372        let dict = PyDict::new(py);
373        dict.set_item("type", stringify!(BettingInstrument))?;
374        dict.set_item("id", self.id.to_string())?;
375        dict.set_item("raw_symbol", self.raw_symbol.to_string())?;
376        dict.set_item("event_type_id", self.event_type_id)?;
377        dict.set_item("event_type_name", self.event_type_name.to_string())?;
378        dict.set_item("competition_id", self.competition_id)?;
379        dict.set_item("competition_name", self.competition_name.to_string())?;
380        dict.set_item("event_id", self.event_id)?;
381        dict.set_item("event_name", self.event_name.to_string())?;
382        dict.set_item("event_country_code", self.event_country_code.to_string())?;
383        dict.set_item("event_open_date", self.event_open_date.as_u64())?;
384        dict.set_item("betting_type", self.betting_type.to_string())?;
385        dict.set_item("market_id", self.market_id.to_string())?;
386        dict.set_item("market_name", self.market_name.to_string())?;
387        dict.set_item("market_type", self.market_type.to_string())?;
388        dict.set_item("market_start_time", self.market_start_time.as_u64())?;
389        dict.set_item("selection_id", self.selection_id)?;
390        dict.set_item("selection_name", self.selection_name.to_string())?;
391        dict.set_item("selection_handicap", self.selection_handicap)?;
392        dict.set_item("currency", self.currency.code.to_string())?;
393        dict.set_item("price_precision", self.price_precision)?;
394        dict.set_item("size_precision", self.size_precision)?;
395        dict.set_item("price_increment", self.price_increment.to_string())?;
396        dict.set_item("size_increment", self.size_increment.to_string())?;
397        dict.set_item("margin_init", self.margin_init.to_string())?;
398        dict.set_item("margin_maint", self.margin_maint.to_string())?;
399        dict.set_item("ts_event", self.ts_event.as_u64())?;
400        dict.set_item("ts_init", self.ts_init.as_u64())?;
401        // Serialize info dict
402        if let Some(ref info_map) = self.info {
403            let info_dict = PyDict::new(py);
404
405            for (key, value) in info_map {
406                let json_str = serde_json::to_string(value).map_err(to_pyvalue_err)?;
407                let py_value =
408                    PyModule::import(py, "json")?.call_method("loads", (json_str,), None)?;
409                info_dict.set_item(key, py_value)?;
410            }
411            dict.set_item("info", info_dict)?;
412        } else {
413            dict.set_item("info", PyDict::new(py))?;
414        }
415
416        match self.max_quantity {
417            Some(value) => dict.set_item("max_quantity", value.to_string())?,
418            None => dict.set_item("max_quantity", py.None())?,
419        }
420
421        match self.min_quantity {
422            Some(value) => dict.set_item("min_quantity", value.to_string())?,
423            None => dict.set_item("min_quantity", py.None())?,
424        }
425
426        match self.max_notional {
427            Some(value) => dict.set_item("max_notional", value.to_string())?,
428            None => dict.set_item("max_notional", py.None())?,
429        }
430
431        match self.min_notional {
432            Some(value) => dict.set_item("min_notional", value.to_string())?,
433            None => dict.set_item("min_notional", py.None())?,
434        }
435
436        match self.max_price {
437            Some(value) => dict.set_item("max_price", value.to_string())?,
438            None => dict.set_item("max_price", py.None())?,
439        }
440
441        match self.min_price {
442            Some(value) => dict.set_item("min_price", value.to_string())?,
443            None => dict.set_item("min_price", py.None())?,
444        }
445        dict.set_item(
446            "tick_scheme",
447            crate::python::instruments::tick_scheme_to_py(self),
448        )?;
449        Ok(dict.into())
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use pyo3::{prelude::*, types::PyDict};
456    use rstest::rstest;
457
458    use crate::instruments::{BettingInstrument, stubs::*};
459
460    #[rstest]
461    fn test_dict_round_trip(betting: BettingInstrument) {
462        Python::initialize();
463        Python::attach(|py| {
464            let values = betting.py_to_dict(py).unwrap();
465            let values: Py<PyDict> = values.extract(py).unwrap();
466            let new_betting = BettingInstrument::py_from_dict(py, values).unwrap();
467            assert_eq!(betting, new_betting);
468        });
469    }
470}