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, 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, check_tick_scheme},
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.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 maximum allowable order quantity.
106    pub max_quantity: Option<Quantity>,
107    /// The minimum allowable order quantity.
108    pub min_quantity: Option<Quantity>,
109    /// The maximum allowable order notional value.
110    pub max_notional: Option<Money>,
111    /// The minimum allowable order notional value.
112    pub min_notional: Option<Money>,
113    /// The maximum allowable quoted price.
114    pub max_price: Option<Price>,
115    /// The minimum allowable quoted price.
116    pub min_price: Option<Price>,
117    /// The registered variable tick scheme name.
118    pub tick_scheme: Option<Ustr>,
119    /// Additional instrument metadata as a JSON-serializable dictionary.
120    pub info: Option<Params>,
121    /// UNIX timestamp (nanoseconds) when the data event occurred.
122    pub ts_event: UnixNanos,
123    /// UNIX timestamp (nanoseconds) when the data object was initialized.
124    pub ts_init: UnixNanos,
125}
126
127#[bon::bon]
128impl BettingInstrument {
129    #[expect(clippy::too_many_arguments)]
130    fn new_checked(
131        instrument_id: InstrumentId,
132        raw_symbol: Symbol,
133        event_type_id: u64,
134        event_type_name: Ustr,
135        competition_id: u64,
136        competition_name: Ustr,
137        event_id: u64,
138        event_name: Ustr,
139        event_country_code: Ustr,
140        event_open_date: UnixNanos,
141        betting_type: Ustr,
142        market_id: Ustr,
143        market_name: Ustr,
144        market_type: Ustr,
145        market_start_time: UnixNanos,
146        selection_id: u64,
147        selection_name: Ustr,
148        selection_handicap: f64,
149        currency: Currency,
150        price_precision: u8,
151        size_precision: u8,
152        price_increment: Price,
153        size_increment: Quantity,
154        max_quantity: Option<Quantity>,
155        min_quantity: Option<Quantity>,
156        max_notional: Option<Money>,
157        min_notional: Option<Money>,
158        max_price: Option<Price>,
159        min_price: Option<Price>,
160        margin_init: Option<Decimal>,
161        margin_maint: Option<Decimal>,
162        tick_scheme: Option<Ustr>,
163        info: Option<Params>,
164        ts_event: UnixNanos,
165        ts_init: UnixNanos,
166    ) -> CorrectnessResult<Self> {
167        check_equal_u8(
168            price_precision,
169            price_increment.precision,
170            stringify!(price_precision),
171            stringify!(price_increment.precision),
172        )?;
173        check_equal_u8(
174            size_precision,
175            size_increment.precision,
176            stringify!(size_precision),
177            stringify!(size_increment.precision),
178        )?;
179        check_positive_price(price_increment, stringify!(price_increment))?;
180        check_positive_quantity(size_increment, stringify!(size_increment))?;
181        check_tick_scheme(tick_scheme)?;
182
183        Ok(Self {
184            id: instrument_id,
185            raw_symbol,
186            event_type_id,
187            event_type_name,
188            competition_id,
189            competition_name,
190            event_id,
191            event_name,
192            event_country_code,
193            event_open_date,
194            betting_type,
195            market_id,
196            market_name,
197            market_type,
198            market_start_time,
199            selection_id,
200            selection_name,
201            selection_handicap,
202            currency,
203            price_precision,
204            size_precision,
205            price_increment,
206            size_increment,
207            max_quantity,
208            min_quantity,
209            max_notional,
210            min_notional,
211            max_price,
212            min_price,
213            margin_init: margin_init.unwrap_or(dec!(1)),
214            margin_maint: margin_maint.unwrap_or(dec!(1)),
215            tick_scheme,
216            info,
217            ts_event,
218            ts_init,
219        })
220    }
221
222    /// Returns a fluent builder for a [`BettingInstrument`] instance.
223    ///
224    /// Required fields are enforced at compile time; optional fields can be omitted and use the
225    /// same defaults as checked construction. The same correctness checks run on `build`.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if any input validation fails.
230    #[builder(start_fn = builder, finish_fn = build)]
231    pub fn build_checked(
232        instrument_id: InstrumentId,
233        raw_symbol: Symbol,
234        event_type_id: u64,
235        event_type_name: Ustr,
236        competition_id: u64,
237        competition_name: Ustr,
238        event_id: u64,
239        event_name: Ustr,
240        event_country_code: Ustr,
241        event_open_date: UnixNanos,
242        betting_type: Ustr,
243        market_id: Ustr,
244        market_name: Ustr,
245        market_type: Ustr,
246        market_start_time: UnixNanos,
247        selection_id: u64,
248        selection_name: Ustr,
249        selection_handicap: f64,
250        currency: Currency,
251        price_precision: u8,
252        size_precision: u8,
253        price_increment: Price,
254        size_increment: Quantity,
255        max_quantity: Option<Quantity>,
256        min_quantity: Option<Quantity>,
257        max_notional: Option<Money>,
258        min_notional: Option<Money>,
259        max_price: Option<Price>,
260        min_price: Option<Price>,
261        margin_init: Option<Decimal>,
262        margin_maint: Option<Decimal>,
263        tick_scheme: Option<Ustr>,
264        info: Option<Params>,
265        ts_event: UnixNanos,
266        ts_init: UnixNanos,
267    ) -> CorrectnessResult<Self> {
268        Self::new_checked(
269            instrument_id,
270            raw_symbol,
271            event_type_id,
272            event_type_name,
273            competition_id,
274            competition_name,
275            event_id,
276            event_name,
277            event_country_code,
278            event_open_date,
279            betting_type,
280            market_id,
281            market_name,
282            market_type,
283            market_start_time,
284            selection_id,
285            selection_name,
286            selection_handicap,
287            currency,
288            price_precision,
289            size_precision,
290            price_increment,
291            size_increment,
292            max_quantity,
293            min_quantity,
294            max_notional,
295            min_notional,
296            max_price,
297            min_price,
298            margin_init,
299            margin_maint,
300            tick_scheme,
301            info,
302            ts_event,
303            ts_init,
304        )
305    }
306
307    fn uses_betfair_tick_scheme(&self) -> bool {
308        self.id.venue.as_str() == BETFAIR_TICK_SCHEME_NAME
309    }
310}
311
312impl PartialEq<Self> for BettingInstrument {
313    fn eq(&self, other: &Self) -> bool {
314        self.id == other.id
315    }
316}
317
318impl Eq for BettingInstrument {}
319
320impl Hash for BettingInstrument {
321    fn hash<H: Hasher>(&self, state: &mut H) {
322        self.id.hash(state);
323    }
324}
325
326impl Instrument for BettingInstrument {
327    fn into_any(self) -> InstrumentAny {
328        InstrumentAny::Betting(self)
329    }
330
331    fn id(&self) -> InstrumentId {
332        self.id
333    }
334
335    fn raw_symbol(&self) -> Symbol {
336        self.raw_symbol
337    }
338
339    fn asset_class(&self) -> AssetClass {
340        AssetClass::Alternative
341    }
342
343    fn instrument_class(&self) -> InstrumentClass {
344        InstrumentClass::SportsBetting
345    }
346
347    fn underlying(&self) -> Option<Ustr> {
348        None
349    }
350
351    fn quote_currency(&self) -> Currency {
352        self.currency
353    }
354
355    fn base_currency(&self) -> Option<Currency> {
356        None
357    }
358
359    fn settlement_currency(&self) -> Currency {
360        self.currency
361    }
362
363    fn isin(&self) -> Option<Ustr> {
364        None
365    }
366
367    fn exchange(&self) -> Option<Ustr> {
368        None
369    }
370
371    fn option_kind(&self) -> Option<OptionKind> {
372        None
373    }
374
375    fn is_inverse(&self) -> bool {
376        false
377    }
378
379    fn price_precision(&self) -> u8 {
380        self.price_precision
381    }
382
383    fn size_precision(&self) -> u8 {
384        self.size_precision
385    }
386
387    fn price_increment(&self) -> Price {
388        self.price_increment
389    }
390
391    fn size_increment(&self) -> Quantity {
392        self.size_increment
393    }
394
395    fn multiplier(&self) -> Quantity {
396        Quantity::from(1)
397    }
398
399    fn lot_size(&self) -> Option<Quantity> {
400        Some(Quantity::from(1))
401    }
402
403    fn max_quantity(&self) -> Option<Quantity> {
404        self.max_quantity
405    }
406
407    fn min_quantity(&self) -> Option<Quantity> {
408        self.min_quantity
409    }
410
411    fn max_price(&self) -> Option<Price> {
412        self.max_price.or_else(|| {
413            self.uses_betfair_tick_scheme()
414                .then(|| BETFAIR_TICK_SCHEME.max_price())
415        })
416    }
417
418    fn min_price(&self) -> Option<Price> {
419        self.min_price.or_else(|| {
420            self.uses_betfair_tick_scheme()
421                .then(|| BETFAIR_TICK_SCHEME.min_price())
422        })
423    }
424
425    fn tick_scheme(&self) -> Option<Ustr> {
426        self.tick_scheme.or_else(|| {
427            self.uses_betfair_tick_scheme()
428                .then(|| Ustr::from(BETFAIR_TICK_SCHEME_NAME))
429        })
430    }
431
432    fn info(&self) -> Option<&Params> {
433        self.info.as_ref()
434    }
435
436    fn ts_event(&self) -> UnixNanos {
437        self.ts_event
438    }
439
440    fn ts_init(&self) -> UnixNanos {
441        self.ts_init
442    }
443
444    fn margin_init(&self) -> Decimal {
445        self.margin_init
446    }
447
448    fn margin_maint(&self) -> Decimal {
449        self.margin_maint
450    }
451
452    fn strike_price(&self) -> Option<Price> {
453        None
454    }
455
456    fn activation_ns(&self) -> Option<UnixNanos> {
457        Some(self.market_start_time)
458    }
459
460    fn expiration_ns(&self) -> Option<UnixNanos> {
461        None
462    }
463
464    fn max_notional(&self) -> Option<Money> {
465        self.max_notional
466    }
467
468    fn min_notional(&self) -> Option<Money> {
469        self.min_notional
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use rstest::rstest;
476    use rust_decimal_macros::dec;
477
478    use crate::{
479        enums::{AssetClass, InstrumentClass},
480        identifiers::{InstrumentId, Symbol},
481        instruments::{BettingInstrument, Instrument, stubs::*},
482        types::{Currency, Money, Price, Quantity},
483    };
484
485    #[rstest]
486    fn test_trait_accessors(betting: BettingInstrument) {
487        assert_eq!(betting.asset_class(), AssetClass::Alternative);
488        assert_eq!(betting.instrument_class(), InstrumentClass::SportsBetting);
489        assert_eq!(betting.quote_currency(), Currency::GBP());
490        assert!(!betting.is_inverse());
491        assert_eq!(betting.price_precision(), 2);
492        assert_eq!(betting.size_precision(), 2);
493        assert_eq!(betting.price_increment(), Price::from("0.01"));
494        assert_eq!(betting.size_increment(), Quantity::from("0.01"));
495        assert_eq!(betting.margin_init(), dec!(1));
496        assert_eq!(betting.margin_maint(), dec!(1));
497    }
498
499    #[rstest]
500    fn test_new_checked_price_precision_mismatch() {
501        let result = BettingInstrument::new_checked(
502            InstrumentId::from("1-123.BETFAIR"),
503            "1-123".into(),
504            6423,
505            "Football".into(),
506            1,
507            "NFL".into(),
508            1,
509            "NFL".into(),
510            "GB".into(),
511            0.into(),
512            "ODDS".into(),
513            "1-123".into(),
514            "Winner".into(),
515            "SPECIAL".into(),
516            0.into(),
517            50214,
518            "Team".into(),
519            0.0,
520            Currency::GBP(),
521            4, // mismatch
522            2,
523            Price::from("0.01"),
524            Quantity::from("0.01"),
525            None,
526            None,
527            None,
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            0.into(),
536            0.into(),
537        );
538        assert!(result.is_err());
539    }
540
541    #[rstest]
542    fn test_serialization_roundtrip(betting: BettingInstrument) {
543        let json = serde_json::to_string(&betting).unwrap();
544        let deserialized: BettingInstrument = serde_json::from_str(&json).unwrap();
545        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
546    }
547
548    #[rstest]
549    fn test_betfair_tick_scheme_navigation(mut betting: BettingInstrument) {
550        betting.max_price = None;
551        betting.min_price = None;
552
553        assert_eq!(betting.min_price(), Some(Price::from("1.01")));
554        assert_eq!(betting.max_price(), Some(Price::from("1000.00")));
555        assert_eq!(betting.next_ask_price(4.0, 1), Some(Price::from("4.10")));
556        assert_eq!(betting.next_bid_price(2.027, 2), Some(Price::from("1.99")));
557        assert_eq!(betting.next_bid_prices(1.102, 20).len(), 10);
558        assert_eq!(betting.next_ask_prices(1.102, 20).len(), 20);
559    }
560
561    #[rstest]
562    fn test_non_betfair_venue_no_tick_scheme(mut betting: BettingInstrument) {
563        betting.id = InstrumentId::from("1-123456789.SMARKETS");
564        betting.max_price = None;
565        betting.min_price = None;
566
567        assert!(betting.tick_scheme().is_none());
568        assert!(betting.min_price().is_none());
569        assert!(betting.max_price().is_none());
570    }
571
572    #[rstest]
573    fn test_builder_matches_new_checked() {
574        let positional = BettingInstrument::new_checked(
575            InstrumentId::from("1-123456789.BETFAIR"),
576            Symbol::from("1-123456789"),
577            6423,
578            "American Football".into(),
579            12_282_733,
580            "NFL".into(),
581            29_678_534,
582            "NFL".into(),
583            "GB".into(),
584            1.into(),
585            "ODDS".into(),
586            "1-123456789".into(),
587            "AFC Conference Winner".into(),
588            "SPECIAL".into(),
589            2.into(),
590            50214,
591            "Kansas City Chiefs".into(),
592            0.0,
593            Currency::GBP(),
594            2,
595            2,
596            Price::from("0.01"),
597            Quantity::from("0.01"),
598            Some(Quantity::from("1000")),
599            Some(Quantity::from("1")),
600            Some(Money::from("10000 GBP")),
601            Some(Money::from("10 GBP")),
602            Some(Price::from("100.00")),
603            Some(Price::from("1.00")),
604            Some(dec!(0.01)),
605            Some(dec!(0.02)),
606            None,
607            None,
608            3.into(),
609            4.into(),
610        )
611        .unwrap();
612
613        let built = BettingInstrument::builder()
614            .instrument_id(InstrumentId::from("1-123456789.BETFAIR"))
615            .raw_symbol(Symbol::from("1-123456789"))
616            .event_type_id(6423)
617            .event_type_name("American Football".into())
618            .competition_id(12_282_733)
619            .competition_name("NFL".into())
620            .event_id(29_678_534)
621            .event_name("NFL".into())
622            .event_country_code("GB".into())
623            .event_open_date(1.into())
624            .betting_type("ODDS".into())
625            .market_id("1-123456789".into())
626            .market_name("AFC Conference Winner".into())
627            .market_type("SPECIAL".into())
628            .market_start_time(2.into())
629            .selection_id(50214)
630            .selection_name("Kansas City Chiefs".into())
631            .selection_handicap(0.0)
632            .currency(Currency::GBP())
633            .price_precision(2)
634            .size_precision(2)
635            .price_increment(Price::from("0.01"))
636            .size_increment(Quantity::from("0.01"))
637            .max_quantity(Quantity::from("1000"))
638            .min_quantity(Quantity::from("1"))
639            .max_notional(Money::from("10000 GBP"))
640            .min_notional(Money::from("10 GBP"))
641            .max_price(Price::from("100.00"))
642            .min_price(Price::from("1.00"))
643            .margin_init(dec!(0.01))
644            .margin_maint(dec!(0.02))
645            .ts_event(3.into())
646            .ts_init(4.into())
647            .build()
648            .unwrap();
649
650        assert_eq!(
651            serde_json::to_value(&positional).unwrap(),
652            serde_json::to_value(&built).unwrap(),
653        );
654    }
655}