1use 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#[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 pub id: InstrumentId,
57 pub raw_symbol: Symbol,
59 pub event_type_id: u64,
61 pub event_type_name: Ustr,
63 pub competition_id: u64,
65 pub competition_name: Ustr,
67 pub event_id: u64,
69 pub event_name: Ustr,
71 pub event_country_code: Ustr,
73 pub event_open_date: UnixNanos,
75 pub betting_type: Ustr,
77 pub market_id: Ustr,
79 pub market_name: Ustr,
81 pub market_type: Ustr,
83 pub market_start_time: UnixNanos,
85 pub selection_id: u64,
87 pub selection_name: Ustr,
89 pub selection_handicap: f64,
91 pub currency: Currency,
93 pub price_precision: u8,
95 pub size_precision: u8,
97 pub price_increment: Price,
99 pub size_increment: Quantity,
101 pub margin_init: Decimal,
103 pub margin_maint: Decimal,
105 pub maker_fee: Decimal,
107 pub taker_fee: Decimal,
109 pub max_quantity: Option<Quantity>,
111 pub min_quantity: Option<Quantity>,
113 pub max_notional: Option<Money>,
115 pub min_notional: Option<Money>,
117 pub max_price: Option<Price>,
119 pub min_price: Option<Price>,
121 pub tick_scheme: Option<Ustr>,
123 pub info: Option<Params>,
125 pub ts_event: UnixNanos,
127 pub ts_init: UnixNanos,
129}
130
131#[bon::bon]
132impl BettingInstrument {
133 #[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 #[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 #[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, 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}