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