1use 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#[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 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)]
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 #[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, 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}