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, 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.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    /// 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    /// Creates a new [`BettingInstrument`] instance with correctness checking.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if any input validation fails (precision mismatches or non-positive increments).
138    ///
139    /// # Notes
140    ///
141    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
142    #[expect(clippy::too_many_arguments)]
143    pub fn new_checked(
144        instrument_id: InstrumentId,
145        raw_symbol: Symbol,
146        event_type_id: u64,
147        event_type_name: Ustr,
148        competition_id: u64,
149        competition_name: Ustr,
150        event_id: u64,
151        event_name: Ustr,
152        event_country_code: Ustr,
153        event_open_date: UnixNanos,
154        betting_type: Ustr,
155        market_id: Ustr,
156        market_name: Ustr,
157        market_type: Ustr,
158        market_start_time: UnixNanos,
159        selection_id: u64,
160        selection_name: Ustr,
161        selection_handicap: f64,
162        currency: Currency,
163        price_precision: u8,
164        size_precision: u8,
165        price_increment: Price,
166        size_increment: Quantity,
167        max_quantity: Option<Quantity>,
168        min_quantity: Option<Quantity>,
169        max_notional: Option<Money>,
170        min_notional: Option<Money>,
171        max_price: Option<Price>,
172        min_price: Option<Price>,
173        margin_init: Option<Decimal>,
174        margin_maint: Option<Decimal>,
175        maker_fee: Option<Decimal>,
176        taker_fee: Option<Decimal>,
177        tick_scheme: Option<Ustr>,
178        info: Option<Params>,
179        ts_event: UnixNanos,
180        ts_init: UnixNanos,
181    ) -> CorrectnessResult<Self> {
182        check_equal_u8(
183            price_precision,
184            price_increment.precision,
185            stringify!(price_precision),
186            stringify!(price_increment.precision),
187        )?;
188        check_equal_u8(
189            size_precision,
190            size_increment.precision,
191            stringify!(size_precision),
192            stringify!(size_increment.precision),
193        )?;
194        check_positive_price(price_increment, stringify!(price_increment))?;
195        check_positive_quantity(size_increment, stringify!(size_increment))?;
196        check_tick_scheme(tick_scheme)?;
197
198        Ok(Self {
199            id: instrument_id,
200            raw_symbol,
201            event_type_id,
202            event_type_name,
203            competition_id,
204            competition_name,
205            event_id,
206            event_name,
207            event_country_code,
208            event_open_date,
209            betting_type,
210            market_id,
211            market_name,
212            market_type,
213            market_start_time,
214            selection_id,
215            selection_name,
216            selection_handicap,
217            currency,
218            price_precision,
219            size_precision,
220            price_increment,
221            size_increment,
222            max_quantity,
223            min_quantity,
224            max_notional,
225            min_notional,
226            max_price,
227            min_price,
228            margin_init: margin_init.unwrap_or(dec!(1)),
229            margin_maint: margin_maint.unwrap_or(dec!(1)),
230            maker_fee: maker_fee.unwrap_or_default(),
231            taker_fee: taker_fee.unwrap_or_default(),
232            tick_scheme,
233            info,
234            ts_event,
235            ts_init,
236        })
237    }
238
239    /// Creates a new [`BettingInstrument`] instance by parsing and validating input parameters.
240    ///
241    /// # Panics
242    ///
243    /// Panics if any required parameter is invalid or parsing fails during `new_checked`.
244    #[expect(clippy::too_many_arguments)]
245    #[must_use]
246    pub fn new(
247        instrument_id: InstrumentId,
248        raw_symbol: Symbol,
249        event_type_id: u64,
250        event_type_name: Ustr,
251        competition_id: u64,
252        competition_name: Ustr,
253        event_id: u64,
254        event_name: Ustr,
255        event_country_code: Ustr,
256        event_open_date: UnixNanos,
257        betting_type: Ustr,
258        market_id: Ustr,
259        market_name: Ustr,
260        market_type: Ustr,
261        market_start_time: UnixNanos,
262        selection_id: u64,
263        selection_name: Ustr,
264        selection_handicap: f64,
265        currency: Currency,
266        price_precision: u8,
267        size_precision: u8,
268        price_increment: Price,
269        size_increment: Quantity,
270        max_quantity: Option<Quantity>,
271        min_quantity: Option<Quantity>,
272        max_notional: Option<Money>,
273        min_notional: Option<Money>,
274        max_price: Option<Price>,
275        min_price: Option<Price>,
276        margin_init: Option<Decimal>,
277        margin_maint: Option<Decimal>,
278        maker_fee: Option<Decimal>,
279        taker_fee: Option<Decimal>,
280        tick_scheme: Option<Ustr>,
281        info: Option<Params>,
282        ts_event: UnixNanos,
283        ts_init: UnixNanos,
284    ) -> Self {
285        Self::new_checked(
286            instrument_id,
287            raw_symbol,
288            event_type_id,
289            event_type_name,
290            competition_id,
291            competition_name,
292            event_id,
293            event_name,
294            event_country_code,
295            event_open_date,
296            betting_type,
297            market_id,
298            market_name,
299            market_type,
300            market_start_time,
301            selection_id,
302            selection_name,
303            selection_handicap,
304            currency,
305            price_precision,
306            size_precision,
307            price_increment,
308            size_increment,
309            max_quantity,
310            min_quantity,
311            max_notional,
312            min_notional,
313            max_price,
314            min_price,
315            margin_init,
316            margin_maint,
317            maker_fee,
318            taker_fee,
319            tick_scheme,
320            info,
321            ts_event,
322            ts_init,
323        )
324        .expect_display(FAILED)
325    }
326
327    /// Returns a fluent builder for a [`BettingInstrument`] instance.
328    ///
329    /// Required fields are enforced at compile time; optional fields can be omitted and default
330    /// the same way they do in [`BettingInstrument::new_checked`], which the builder calls so the
331    /// same correctness checks run on `build`.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if any input validation fails (see [`BettingInstrument::new_checked`]).
336    #[builder(start_fn = builder, finish_fn = build)]
337    pub fn build_checked(
338        instrument_id: InstrumentId,
339        raw_symbol: Symbol,
340        event_type_id: u64,
341        event_type_name: Ustr,
342        competition_id: u64,
343        competition_name: Ustr,
344        event_id: u64,
345        event_name: Ustr,
346        event_country_code: Ustr,
347        event_open_date: UnixNanos,
348        betting_type: Ustr,
349        market_id: Ustr,
350        market_name: Ustr,
351        market_type: Ustr,
352        market_start_time: UnixNanos,
353        selection_id: u64,
354        selection_name: Ustr,
355        selection_handicap: f64,
356        currency: Currency,
357        price_precision: u8,
358        size_precision: u8,
359        price_increment: Price,
360        size_increment: Quantity,
361        max_quantity: Option<Quantity>,
362        min_quantity: Option<Quantity>,
363        max_notional: Option<Money>,
364        min_notional: Option<Money>,
365        max_price: Option<Price>,
366        min_price: Option<Price>,
367        margin_init: Option<Decimal>,
368        margin_maint: Option<Decimal>,
369        maker_fee: Option<Decimal>,
370        taker_fee: Option<Decimal>,
371        tick_scheme: Option<Ustr>,
372        info: Option<Params>,
373        ts_event: UnixNanos,
374        ts_init: UnixNanos,
375    ) -> CorrectnessResult<Self> {
376        Self::new_checked(
377            instrument_id,
378            raw_symbol,
379            event_type_id,
380            event_type_name,
381            competition_id,
382            competition_name,
383            event_id,
384            event_name,
385            event_country_code,
386            event_open_date,
387            betting_type,
388            market_id,
389            market_name,
390            market_type,
391            market_start_time,
392            selection_id,
393            selection_name,
394            selection_handicap,
395            currency,
396            price_precision,
397            size_precision,
398            price_increment,
399            size_increment,
400            max_quantity,
401            min_quantity,
402            max_notional,
403            min_notional,
404            max_price,
405            min_price,
406            margin_init,
407            margin_maint,
408            maker_fee,
409            taker_fee,
410            tick_scheme,
411            info,
412            ts_event,
413            ts_init,
414        )
415    }
416
417    fn uses_betfair_tick_scheme(&self) -> bool {
418        self.id.venue.as_str() == BETFAIR_TICK_SCHEME_NAME
419    }
420}
421
422impl PartialEq<Self> for BettingInstrument {
423    fn eq(&self, other: &Self) -> bool {
424        self.id == other.id
425    }
426}
427
428impl Eq for BettingInstrument {}
429
430impl Hash for BettingInstrument {
431    fn hash<H: Hasher>(&self, state: &mut H) {
432        self.id.hash(state);
433    }
434}
435
436impl Instrument for BettingInstrument {
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 into_any(self) -> InstrumentAny {
445        InstrumentAny::Betting(self)
446    }
447
448    fn id(&self) -> InstrumentId {
449        self.id
450    }
451
452    fn raw_symbol(&self) -> Symbol {
453        self.raw_symbol
454    }
455
456    fn asset_class(&self) -> AssetClass {
457        AssetClass::Alternative
458    }
459
460    fn instrument_class(&self) -> InstrumentClass {
461        InstrumentClass::SportsBetting
462    }
463
464    fn underlying(&self) -> Option<Ustr> {
465        None
466    }
467
468    fn quote_currency(&self) -> Currency {
469        self.currency
470    }
471
472    fn base_currency(&self) -> Option<Currency> {
473        None
474    }
475
476    fn settlement_currency(&self) -> Currency {
477        self.currency
478    }
479
480    fn isin(&self) -> Option<Ustr> {
481        None
482    }
483
484    fn exchange(&self) -> Option<Ustr> {
485        None
486    }
487
488    fn option_kind(&self) -> Option<OptionKind> {
489        None
490    }
491
492    fn is_inverse(&self) -> bool {
493        false
494    }
495
496    fn price_precision(&self) -> u8 {
497        self.price_precision
498    }
499
500    fn size_precision(&self) -> u8 {
501        self.size_precision
502    }
503
504    fn price_increment(&self) -> Price {
505        self.price_increment
506    }
507
508    fn size_increment(&self) -> Quantity {
509        self.size_increment
510    }
511
512    fn multiplier(&self) -> Quantity {
513        Quantity::from(1)
514    }
515
516    fn lot_size(&self) -> Option<Quantity> {
517        Some(Quantity::from(1))
518    }
519
520    fn max_quantity(&self) -> Option<Quantity> {
521        self.max_quantity
522    }
523
524    fn min_quantity(&self) -> Option<Quantity> {
525        self.min_quantity
526    }
527
528    fn max_price(&self) -> Option<Price> {
529        self.max_price.or_else(|| {
530            self.uses_betfair_tick_scheme()
531                .then(|| BETFAIR_TICK_SCHEME.max_price())
532        })
533    }
534
535    fn min_price(&self) -> Option<Price> {
536        self.min_price.or_else(|| {
537            self.uses_betfair_tick_scheme()
538                .then(|| BETFAIR_TICK_SCHEME.min_price())
539        })
540    }
541
542    fn ts_event(&self) -> UnixNanos {
543        self.ts_event
544    }
545
546    fn ts_init(&self) -> UnixNanos {
547        self.ts_init
548    }
549
550    fn margin_init(&self) -> Decimal {
551        self.margin_init
552    }
553
554    fn margin_maint(&self) -> Decimal {
555        self.margin_maint
556    }
557
558    fn maker_fee(&self) -> Decimal {
559        self.maker_fee
560    }
561
562    fn taker_fee(&self) -> Decimal {
563        self.taker_fee
564    }
565
566    fn strike_price(&self) -> Option<Price> {
567        None
568    }
569
570    fn activation_ns(&self) -> Option<UnixNanos> {
571        Some(self.market_start_time)
572    }
573
574    fn expiration_ns(&self) -> Option<UnixNanos> {
575        None
576    }
577
578    fn max_notional(&self) -> Option<Money> {
579        self.max_notional
580    }
581
582    fn min_notional(&self) -> Option<Money> {
583        self.min_notional
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use rstest::rstest;
590    use rust_decimal_macros::dec;
591
592    use crate::{
593        enums::{AssetClass, InstrumentClass},
594        identifiers::{InstrumentId, Symbol},
595        instruments::{BettingInstrument, Instrument, stubs::*},
596        types::{Currency, Money, Price, Quantity},
597    };
598
599    #[rstest]
600    fn test_trait_accessors(betting: BettingInstrument) {
601        assert_eq!(betting.asset_class(), AssetClass::Alternative);
602        assert_eq!(betting.instrument_class(), InstrumentClass::SportsBetting);
603        assert_eq!(betting.quote_currency(), Currency::GBP());
604        assert!(!betting.is_inverse());
605        assert_eq!(betting.price_precision(), 2);
606        assert_eq!(betting.size_precision(), 2);
607        assert_eq!(betting.price_increment(), Price::from("0.01"));
608        assert_eq!(betting.size_increment(), Quantity::from("0.01"));
609        assert_eq!(betting.margin_init(), dec!(1));
610        assert_eq!(betting.margin_maint(), dec!(1));
611    }
612
613    #[rstest]
614    fn test_new_checked_price_precision_mismatch() {
615        let result = BettingInstrument::new_checked(
616            InstrumentId::from("1-123.BETFAIR"),
617            "1-123".into(),
618            6423,
619            "Football".into(),
620            1,
621            "NFL".into(),
622            1,
623            "NFL".into(),
624            "GB".into(),
625            0.into(),
626            "ODDS".into(),
627            "1-123".into(),
628            "Winner".into(),
629            "SPECIAL".into(),
630            0.into(),
631            50214,
632            "Team".into(),
633            0.0,
634            Currency::GBP(),
635            4, // mismatch
636            2,
637            Price::from("0.01"),
638            Quantity::from("0.01"),
639            None,
640            None,
641            None,
642            None,
643            None,
644            None,
645            None,
646            None,
647            None,
648            None,
649            None,
650            None,
651            0.into(),
652            0.into(),
653        );
654        assert!(result.is_err());
655    }
656
657    #[rstest]
658    fn test_serialization_roundtrip(betting: BettingInstrument) {
659        let json = serde_json::to_string(&betting).unwrap();
660        let deserialized: BettingInstrument = serde_json::from_str(&json).unwrap();
661        assert_eq!(betting, deserialized);
662    }
663
664    #[rstest]
665    fn test_betfair_tick_scheme_navigation(mut betting: BettingInstrument) {
666        betting.max_price = None;
667        betting.min_price = None;
668
669        assert_eq!(betting.min_price(), Some(Price::from("1.01")));
670        assert_eq!(betting.max_price(), Some(Price::from("1000.00")));
671        assert_eq!(betting.next_ask_price(4.0, 1), Some(Price::from("4.10")));
672        assert_eq!(betting.next_bid_price(2.027, 2), Some(Price::from("1.99")));
673        assert_eq!(betting.next_bid_prices(1.102, 20).len(), 10);
674        assert_eq!(betting.next_ask_prices(1.102, 20).len(), 20);
675    }
676
677    #[rstest]
678    fn test_non_betfair_venue_no_tick_scheme(mut betting: BettingInstrument) {
679        betting.id = InstrumentId::from("1-123456789.SMARKETS");
680        betting.max_price = None;
681        betting.min_price = None;
682
683        assert!(betting.tick_scheme().is_none());
684        assert!(betting.min_price().is_none());
685        assert!(betting.max_price().is_none());
686    }
687
688    #[rstest]
689    fn test_builder_matches_new_checked() {
690        let positional = BettingInstrument::new_checked(
691            InstrumentId::from("1-123456789.BETFAIR"),
692            Symbol::from("1-123456789"),
693            6423,
694            "American Football".into(),
695            12_282_733,
696            "NFL".into(),
697            29_678_534,
698            "NFL".into(),
699            "GB".into(),
700            1.into(),
701            "ODDS".into(),
702            "1-123456789".into(),
703            "AFC Conference Winner".into(),
704            "SPECIAL".into(),
705            2.into(),
706            50214,
707            "Kansas City Chiefs".into(),
708            0.0,
709            Currency::GBP(),
710            2,
711            2,
712            Price::from("0.01"),
713            Quantity::from("0.01"),
714            Some(Quantity::from("1000")),
715            Some(Quantity::from("1")),
716            Some(Money::from("10000 GBP")),
717            Some(Money::from("10 GBP")),
718            Some(Price::from("100.00")),
719            Some(Price::from("1.00")),
720            Some(dec!(0.01)),
721            Some(dec!(0.02)),
722            Some(dec!(0.0002)),
723            Some(dec!(0.0004)),
724            None,
725            None,
726            3.into(),
727            4.into(),
728        )
729        .unwrap();
730
731        let built = BettingInstrument::builder()
732            .instrument_id(InstrumentId::from("1-123456789.BETFAIR"))
733            .raw_symbol(Symbol::from("1-123456789"))
734            .event_type_id(6423)
735            .event_type_name("American Football".into())
736            .competition_id(12_282_733)
737            .competition_name("NFL".into())
738            .event_id(29_678_534)
739            .event_name("NFL".into())
740            .event_country_code("GB".into())
741            .event_open_date(1.into())
742            .betting_type("ODDS".into())
743            .market_id("1-123456789".into())
744            .market_name("AFC Conference Winner".into())
745            .market_type("SPECIAL".into())
746            .market_start_time(2.into())
747            .selection_id(50214)
748            .selection_name("Kansas City Chiefs".into())
749            .selection_handicap(0.0)
750            .currency(Currency::GBP())
751            .price_precision(2)
752            .size_precision(2)
753            .price_increment(Price::from("0.01"))
754            .size_increment(Quantity::from("0.01"))
755            .max_quantity(Quantity::from("1000"))
756            .min_quantity(Quantity::from("1"))
757            .max_notional(Money::from("10000 GBP"))
758            .min_notional(Money::from("10 GBP"))
759            .max_price(Price::from("100.00"))
760            .min_price(Price::from("1.00"))
761            .margin_init(dec!(0.01))
762            .margin_maint(dec!(0.02))
763            .maker_fee(dec!(0.0002))
764            .taker_fee(dec!(0.0004))
765            .ts_event(3.into())
766            .ts_init(4.into())
767            .build()
768            .unwrap();
769
770        assert_eq!(
771            serde_json::to_value(&positional).unwrap(),
772            serde_json::to_value(&built).unwrap(),
773        );
774    }
775}