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 max_quantity: Option<Quantity>,
107 pub min_quantity: Option<Quantity>,
109 pub max_notional: Option<Money>,
111 pub min_notional: Option<Money>,
113 pub max_price: Option<Price>,
115 pub min_price: Option<Price>,
117 pub tick_scheme: Option<Ustr>,
119 pub info: Option<Params>,
121 pub ts_event: UnixNanos,
123 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 #[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, 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}