Skip to main content

nautilus_model/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::hash::{Hash, Hasher};
17
18use nautilus_core::{
19    Params, UnixNanos,
20    correctness::{CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use rust_decimal_macros::dec;
24use serde::{Deserialize, Serialize};
25use ustr::Ustr;
26
27use super::{
28    Instrument,
29    any::InstrumentAny,
30    tick_scheme::{BETFAIR_TICK_SCHEME, BETFAIR_TICK_SCHEME_NAME, TickSchemeRule},
31};
32use crate::{
33    enums::{AssetClass, InstrumentClass, OptionKind},
34    identifiers::{InstrumentId, Symbol},
35    types::{
36        currency::Currency,
37        money::Money,
38        price::{Price, check_positive_price},
39        quantity::{Quantity, check_positive_quantity},
40    },
41};
42
43/// Represents a betting instrument with complete market and selection details.
44#[repr(C)]
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[cfg_attr(
47    feature = "python",
48    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
49)]
50#[cfg_attr(
51    feature = "python",
52    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
53)]
54pub struct BettingInstrument {
55    /// The instrument ID.
56    pub id: InstrumentId,
57    /// The raw/local/native symbol for the instrument, assigned by the venue.
58    pub raw_symbol: Symbol,
59    /// The event type identifier (e.g. 1=Soccer, 2=Tennis).
60    pub event_type_id: u64,
61    /// The name of the event type (e.g. "Soccer", "Tennis").
62    pub event_type_name: Ustr,
63    /// The competition/league identifier.
64    pub competition_id: u64,
65    /// The name of the competition (e.g. "English Premier League").
66    pub competition_name: Ustr,
67    /// The unique identifier for the event.
68    pub event_id: u64,
69    /// The name of the event (e.g. "Arsenal vs Chelsea").
70    pub event_name: Ustr,
71    /// The ISO country code where the event takes place.
72    pub event_country_code: Ustr,
73    /// UNIX timestamp (nanoseconds) when the event becomes available for betting.
74    pub event_open_date: UnixNanos,
75    /// The type of betting (e.g. "ODDS", "LINE").
76    pub betting_type: Ustr,
77    /// The unique identifier for the betting market.
78    pub market_id: Ustr,
79    /// The name of the market (e.g. "Match Odds", "Total Goals").
80    pub market_name: Ustr,
81    /// The type of market (e.g. "WIN", "PLACE").
82    pub market_type: Ustr,
83    /// UNIX timestamp (nanoseconds) when betting starts for this market.
84    pub market_start_time: UnixNanos,
85    /// The unique identifier for the selection within the market.
86    pub selection_id: u64,
87    /// The name of the selection (e.g. "Arsenal", "Over 2.5").
88    pub selection_name: Ustr,
89    /// The handicap value for the selection, if applicable.
90    pub selection_handicap: f64,
91    /// The contract currency.
92    pub currency: Currency,
93    /// The price decimal precision.
94    pub price_precision: u8,
95    /// The trading size decimal precision.
96    pub size_precision: u8,
97    /// The minimum price increment (tick size).
98    pub price_increment: Price,
99    /// The minimum size increment.
100    pub size_increment: Quantity,
101    /// The initial (order) margin requirement in percentage of order value.
102    pub margin_init: Decimal,
103    /// The maintenance (position) margin in percentage of position value.
104    pub margin_maint: Decimal,
105    /// The fee rate for liquidity makers as a percentage of order value.
106    pub maker_fee: Decimal,
107    /// The fee rate for liquidity takers as a percentage of order value.
108    pub taker_fee: Decimal,
109    /// The maximum allowable order quantity.
110    pub max_quantity: Option<Quantity>,
111    /// The minimum allowable order quantity.
112    pub min_quantity: Option<Quantity>,
113    /// The maximum allowable order notional value.
114    pub max_notional: Option<Money>,
115    /// The minimum allowable order notional value.
116    pub min_notional: Option<Money>,
117    /// The maximum allowable quoted price.
118    pub max_price: Option<Price>,
119    /// The minimum allowable quoted price.
120    pub min_price: Option<Price>,
121    /// Additional instrument metadata as a JSON-serializable dictionary.
122    pub info: Option<Params>,
123    /// UNIX timestamp (nanoseconds) when the data event occurred.
124    pub ts_event: UnixNanos,
125    /// UNIX timestamp (nanoseconds) when the data object was initialized.
126    pub ts_init: UnixNanos,
127}
128
129impl BettingInstrument {
130    /// Creates a new [`BettingInstrument`] instance with correctness checking.
131    ///
132    /// # Notes
133    ///
134    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
135    /// # Errors
136    ///
137    /// Returns an error if any input validation fails (precision mismatches or non-positive increments).
138    #[expect(clippy::too_many_arguments)]
139    pub fn new_checked(
140        instrument_id: InstrumentId,
141        raw_symbol: Symbol,
142        event_type_id: u64,
143        event_type_name: Ustr,
144        competition_id: u64,
145        competition_name: Ustr,
146        event_id: u64,
147        event_name: Ustr,
148        event_country_code: Ustr,
149        event_open_date: UnixNanos,
150        betting_type: Ustr,
151        market_id: Ustr,
152        market_name: Ustr,
153        market_type: Ustr,
154        market_start_time: UnixNanos,
155        selection_id: u64,
156        selection_name: Ustr,
157        selection_handicap: f64,
158        currency: Currency,
159        price_precision: u8,
160        size_precision: u8,
161        price_increment: Price,
162        size_increment: Quantity,
163        max_quantity: Option<Quantity>,
164        min_quantity: Option<Quantity>,
165        max_notional: Option<Money>,
166        min_notional: Option<Money>,
167        max_price: Option<Price>,
168        min_price: Option<Price>,
169        margin_init: Option<Decimal>,
170        margin_maint: Option<Decimal>,
171        maker_fee: Option<Decimal>,
172        taker_fee: Option<Decimal>,
173        info: Option<Params>,
174        ts_event: UnixNanos,
175        ts_init: UnixNanos,
176    ) -> CorrectnessResult<Self> {
177        check_equal_u8(
178            price_precision,
179            price_increment.precision,
180            stringify!(price_precision),
181            stringify!(price_increment.precision),
182        )?;
183        check_equal_u8(
184            size_precision,
185            size_increment.precision,
186            stringify!(size_precision),
187            stringify!(size_increment.precision),
188        )?;
189        check_positive_price(price_increment, stringify!(price_increment))?;
190        check_positive_quantity(size_increment, stringify!(size_increment))?;
191
192        Ok(Self {
193            id: instrument_id,
194            raw_symbol,
195            event_type_id,
196            event_type_name,
197            competition_id,
198            competition_name,
199            event_id,
200            event_name,
201            event_country_code,
202            event_open_date,
203            betting_type,
204            market_id,
205            market_name,
206            market_type,
207            market_start_time,
208            selection_id,
209            selection_name,
210            selection_handicap,
211            currency,
212            price_precision,
213            size_precision,
214            price_increment,
215            size_increment,
216            max_quantity,
217            min_quantity,
218            max_notional,
219            min_notional,
220            max_price,
221            min_price,
222            margin_init: margin_init.unwrap_or(dec!(1)),
223            margin_maint: margin_maint.unwrap_or(dec!(1)),
224            maker_fee: maker_fee.unwrap_or_default(),
225            taker_fee: taker_fee.unwrap_or_default(),
226            info,
227            ts_event,
228            ts_init,
229        })
230    }
231
232    /// Creates a new [`BettingInstrument`] instance by parsing and validating input parameters.
233    ///
234    /// # Panics
235    ///
236    /// Panics if any required parameter is invalid or parsing fails during `new_checked`.
237    #[expect(clippy::too_many_arguments)]
238    #[must_use]
239    pub fn new(
240        instrument_id: InstrumentId,
241        raw_symbol: Symbol,
242        event_type_id: u64,
243        event_type_name: Ustr,
244        competition_id: u64,
245        competition_name: Ustr,
246        event_id: u64,
247        event_name: Ustr,
248        event_country_code: Ustr,
249        event_open_date: UnixNanos,
250        betting_type: Ustr,
251        market_id: Ustr,
252        market_name: Ustr,
253        market_type: Ustr,
254        market_start_time: UnixNanos,
255        selection_id: u64,
256        selection_name: Ustr,
257        selection_handicap: f64,
258        currency: Currency,
259        price_precision: u8,
260        size_precision: u8,
261        price_increment: Price,
262        size_increment: Quantity,
263        max_quantity: Option<Quantity>,
264        min_quantity: Option<Quantity>,
265        max_notional: Option<Money>,
266        min_notional: Option<Money>,
267        max_price: Option<Price>,
268        min_price: Option<Price>,
269        margin_init: Option<Decimal>,
270        margin_maint: Option<Decimal>,
271        maker_fee: Option<Decimal>,
272        taker_fee: Option<Decimal>,
273        info: Option<Params>,
274        ts_event: UnixNanos,
275        ts_init: UnixNanos,
276    ) -> Self {
277        Self::new_checked(
278            instrument_id,
279            raw_symbol,
280            event_type_id,
281            event_type_name,
282            competition_id,
283            competition_name,
284            event_id,
285            event_name,
286            event_country_code,
287            event_open_date,
288            betting_type,
289            market_id,
290            market_name,
291            market_type,
292            market_start_time,
293            selection_id,
294            selection_name,
295            selection_handicap,
296            currency,
297            price_precision,
298            size_precision,
299            price_increment,
300            size_increment,
301            max_quantity,
302            min_quantity,
303            max_notional,
304            min_notional,
305            max_price,
306            min_price,
307            margin_init,
308            margin_maint,
309            maker_fee,
310            taker_fee,
311            info,
312            ts_event,
313            ts_init,
314        )
315        .expect_display(FAILED)
316    }
317
318    fn uses_betfair_tick_scheme(&self) -> bool {
319        self.id.venue.as_str() == BETFAIR_TICK_SCHEME_NAME
320    }
321}
322
323impl PartialEq<Self> for BettingInstrument {
324    fn eq(&self, other: &Self) -> bool {
325        self.id == other.id
326    }
327}
328
329impl Eq for BettingInstrument {}
330
331impl Hash for BettingInstrument {
332    fn hash<H: Hasher>(&self, state: &mut H) {
333        self.id.hash(state);
334    }
335}
336
337impl Instrument for BettingInstrument {
338    fn tick_scheme(&self) -> Option<&dyn TickSchemeRule> {
339        self.uses_betfair_tick_scheme()
340            .then_some(&*BETFAIR_TICK_SCHEME as &dyn TickSchemeRule)
341    }
342
343    fn into_any(self) -> InstrumentAny {
344        InstrumentAny::Betting(self)
345    }
346
347    fn id(&self) -> InstrumentId {
348        self.id
349    }
350
351    fn raw_symbol(&self) -> Symbol {
352        self.raw_symbol
353    }
354
355    fn asset_class(&self) -> AssetClass {
356        AssetClass::Alternative
357    }
358
359    fn instrument_class(&self) -> InstrumentClass {
360        InstrumentClass::SportsBetting
361    }
362
363    fn underlying(&self) -> Option<Ustr> {
364        None
365    }
366
367    fn quote_currency(&self) -> Currency {
368        self.currency
369    }
370
371    fn base_currency(&self) -> Option<Currency> {
372        None
373    }
374
375    fn settlement_currency(&self) -> Currency {
376        self.currency
377    }
378
379    fn isin(&self) -> Option<Ustr> {
380        None
381    }
382
383    fn exchange(&self) -> Option<Ustr> {
384        None
385    }
386
387    fn option_kind(&self) -> Option<OptionKind> {
388        None
389    }
390
391    fn is_inverse(&self) -> bool {
392        false
393    }
394
395    fn price_precision(&self) -> u8 {
396        self.price_precision
397    }
398
399    fn size_precision(&self) -> u8 {
400        self.size_precision
401    }
402
403    fn price_increment(&self) -> Price {
404        self.price_increment
405    }
406
407    fn size_increment(&self) -> Quantity {
408        self.size_increment
409    }
410
411    fn multiplier(&self) -> Quantity {
412        Quantity::from(1)
413    }
414
415    fn lot_size(&self) -> Option<Quantity> {
416        Some(Quantity::from(1))
417    }
418
419    fn max_quantity(&self) -> Option<Quantity> {
420        self.max_quantity
421    }
422
423    fn min_quantity(&self) -> Option<Quantity> {
424        self.min_quantity
425    }
426
427    fn max_price(&self) -> Option<Price> {
428        self.max_price.or_else(|| {
429            self.uses_betfair_tick_scheme()
430                .then(|| BETFAIR_TICK_SCHEME.max_price())
431        })
432    }
433
434    fn min_price(&self) -> Option<Price> {
435        self.min_price.or_else(|| {
436            self.uses_betfair_tick_scheme()
437                .then(|| BETFAIR_TICK_SCHEME.min_price())
438        })
439    }
440
441    fn ts_event(&self) -> UnixNanos {
442        self.ts_event
443    }
444
445    fn ts_init(&self) -> UnixNanos {
446        self.ts_init
447    }
448
449    fn margin_init(&self) -> Decimal {
450        self.margin_init
451    }
452
453    fn margin_maint(&self) -> Decimal {
454        self.margin_maint
455    }
456
457    fn maker_fee(&self) -> Decimal {
458        self.maker_fee
459    }
460
461    fn taker_fee(&self) -> Decimal {
462        self.taker_fee
463    }
464
465    fn strike_price(&self) -> Option<Price> {
466        None
467    }
468
469    fn activation_ns(&self) -> Option<UnixNanos> {
470        Some(self.market_start_time)
471    }
472
473    fn expiration_ns(&self) -> Option<UnixNanos> {
474        None
475    }
476
477    fn max_notional(&self) -> Option<Money> {
478        self.max_notional
479    }
480
481    fn min_notional(&self) -> Option<Money> {
482        self.min_notional
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use rstest::rstest;
489    use rust_decimal_macros::dec;
490
491    use crate::{
492        enums::{AssetClass, InstrumentClass},
493        identifiers::InstrumentId,
494        instruments::{BettingInstrument, Instrument, stubs::*},
495        types::{Currency, Price, Quantity},
496    };
497
498    #[rstest]
499    fn test_trait_accessors(betting: BettingInstrument) {
500        assert_eq!(betting.asset_class(), AssetClass::Alternative);
501        assert_eq!(betting.instrument_class(), InstrumentClass::SportsBetting);
502        assert_eq!(betting.quote_currency(), Currency::GBP());
503        assert!(!betting.is_inverse());
504        assert_eq!(betting.price_precision(), 2);
505        assert_eq!(betting.size_precision(), 2);
506        assert_eq!(betting.price_increment(), Price::from("0.01"));
507        assert_eq!(betting.size_increment(), Quantity::from("0.01"));
508        assert_eq!(betting.margin_init(), dec!(1));
509        assert_eq!(betting.margin_maint(), dec!(1));
510    }
511
512    #[rstest]
513    fn test_new_checked_price_precision_mismatch() {
514        let result = BettingInstrument::new_checked(
515            InstrumentId::from("1-123.BETFAIR"),
516            "1-123".into(),
517            6423,
518            "Football".into(),
519            1,
520            "NFL".into(),
521            1,
522            "NFL".into(),
523            "GB".into(),
524            0.into(),
525            "ODDS".into(),
526            "1-123".into(),
527            "Winner".into(),
528            "SPECIAL".into(),
529            0.into(),
530            50214,
531            "Team".into(),
532            0.0,
533            Currency::GBP(),
534            4, // mismatch
535            2,
536            Price::from("0.01"),
537            Quantity::from("0.01"),
538            None,
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            None,
547            None,
548            None,
549            0.into(),
550            0.into(),
551        );
552        assert!(result.is_err());
553    }
554
555    #[rstest]
556    fn test_serialization_roundtrip(betting: BettingInstrument) {
557        let json = serde_json::to_string(&betting).unwrap();
558        let deserialized: BettingInstrument = serde_json::from_str(&json).unwrap();
559        assert_eq!(betting, deserialized);
560    }
561
562    #[rstest]
563    fn test_betfair_tick_scheme_navigation(mut betting: BettingInstrument) {
564        betting.max_price = None;
565        betting.min_price = None;
566
567        assert_eq!(betting.min_price(), Some(Price::from("1.01")));
568        assert_eq!(betting.max_price(), Some(Price::from("1000.00")));
569        assert_eq!(betting.next_ask_price(4.0, 1), Some(Price::from("4.10")));
570        assert_eq!(betting.next_bid_price(2.027, 2), Some(Price::from("1.99")));
571        assert_eq!(betting.next_bid_prices(1.102, 20).len(), 10);
572        assert_eq!(betting.next_ask_prices(1.102, 20).len(), 20);
573    }
574
575    #[rstest]
576    fn test_non_betfair_venue_no_tick_scheme(mut betting: BettingInstrument) {
577        betting.id = InstrumentId::from("1-123456789.SMARKETS");
578        betting.max_price = None;
579        betting.min_price = None;
580
581        assert!(betting.tick_scheme().is_none());
582        assert!(betting.min_price().is_none());
583        assert!(betting.max_price().is_none());
584    }
585}