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, TickSchemeRule},
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 info: Option<Params>,
123 pub ts_event: UnixNanos,
125 pub ts_init: UnixNanos,
127}
128
129impl BettingInstrument {
130 #[expect(clippy::too_many_arguments)]
139 pub fn new_checked(
140 instrument_id: InstrumentId,
141 raw_symbol: Symbol,
142 event_type_id: u64,
143 event_type_name: Ustr,
144 competition_id: u64,
145 competition_name: Ustr,
146 event_id: u64,
147 event_name: Ustr,
148 event_country_code: Ustr,
149 event_open_date: UnixNanos,
150 betting_type: Ustr,
151 market_id: Ustr,
152 market_name: Ustr,
153 market_type: Ustr,
154 market_start_time: UnixNanos,
155 selection_id: u64,
156 selection_name: Ustr,
157 selection_handicap: f64,
158 currency: Currency,
159 price_precision: u8,
160 size_precision: u8,
161 price_increment: Price,
162 size_increment: Quantity,
163 max_quantity: Option<Quantity>,
164 min_quantity: Option<Quantity>,
165 max_notional: Option<Money>,
166 min_notional: Option<Money>,
167 max_price: Option<Price>,
168 min_price: Option<Price>,
169 margin_init: Option<Decimal>,
170 margin_maint: Option<Decimal>,
171 maker_fee: Option<Decimal>,
172 taker_fee: Option<Decimal>,
173 info: Option<Params>,
174 ts_event: UnixNanos,
175 ts_init: UnixNanos,
176 ) -> CorrectnessResult<Self> {
177 check_equal_u8(
178 price_precision,
179 price_increment.precision,
180 stringify!(price_precision),
181 stringify!(price_increment.precision),
182 )?;
183 check_equal_u8(
184 size_precision,
185 size_increment.precision,
186 stringify!(size_precision),
187 stringify!(size_increment.precision),
188 )?;
189 check_positive_price(price_increment, stringify!(price_increment))?;
190 check_positive_quantity(size_increment, stringify!(size_increment))?;
191
192 Ok(Self {
193 id: instrument_id,
194 raw_symbol,
195 event_type_id,
196 event_type_name,
197 competition_id,
198 competition_name,
199 event_id,
200 event_name,
201 event_country_code,
202 event_open_date,
203 betting_type,
204 market_id,
205 market_name,
206 market_type,
207 market_start_time,
208 selection_id,
209 selection_name,
210 selection_handicap,
211 currency,
212 price_precision,
213 size_precision,
214 price_increment,
215 size_increment,
216 max_quantity,
217 min_quantity,
218 max_notional,
219 min_notional,
220 max_price,
221 min_price,
222 margin_init: margin_init.unwrap_or(dec!(1)),
223 margin_maint: margin_maint.unwrap_or(dec!(1)),
224 maker_fee: maker_fee.unwrap_or_default(),
225 taker_fee: taker_fee.unwrap_or_default(),
226 info,
227 ts_event,
228 ts_init,
229 })
230 }
231
232 #[expect(clippy::too_many_arguments)]
238 #[must_use]
239 pub fn new(
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 info: Option<Params>,
274 ts_event: UnixNanos,
275 ts_init: UnixNanos,
276 ) -> Self {
277 Self::new_checked(
278 instrument_id,
279 raw_symbol,
280 event_type_id,
281 event_type_name,
282 competition_id,
283 competition_name,
284 event_id,
285 event_name,
286 event_country_code,
287 event_open_date,
288 betting_type,
289 market_id,
290 market_name,
291 market_type,
292 market_start_time,
293 selection_id,
294 selection_name,
295 selection_handicap,
296 currency,
297 price_precision,
298 size_precision,
299 price_increment,
300 size_increment,
301 max_quantity,
302 min_quantity,
303 max_notional,
304 min_notional,
305 max_price,
306 min_price,
307 margin_init,
308 margin_maint,
309 maker_fee,
310 taker_fee,
311 info,
312 ts_event,
313 ts_init,
314 )
315 .expect_display(FAILED)
316 }
317
318 fn uses_betfair_tick_scheme(&self) -> bool {
319 self.id.venue.as_str() == BETFAIR_TICK_SCHEME_NAME
320 }
321}
322
323impl PartialEq<Self> for BettingInstrument {
324 fn eq(&self, other: &Self) -> bool {
325 self.id == other.id
326 }
327}
328
329impl Eq for BettingInstrument {}
330
331impl Hash for BettingInstrument {
332 fn hash<H: Hasher>(&self, state: &mut H) {
333 self.id.hash(state);
334 }
335}
336
337impl Instrument for BettingInstrument {
338 fn tick_scheme(&self) -> Option<&dyn TickSchemeRule> {
339 self.uses_betfair_tick_scheme()
340 .then_some(&*BETFAIR_TICK_SCHEME as &dyn TickSchemeRule)
341 }
342
343 fn into_any(self) -> InstrumentAny {
344 InstrumentAny::Betting(self)
345 }
346
347 fn id(&self) -> InstrumentId {
348 self.id
349 }
350
351 fn raw_symbol(&self) -> Symbol {
352 self.raw_symbol
353 }
354
355 fn asset_class(&self) -> AssetClass {
356 AssetClass::Alternative
357 }
358
359 fn instrument_class(&self) -> InstrumentClass {
360 InstrumentClass::SportsBetting
361 }
362
363 fn underlying(&self) -> Option<Ustr> {
364 None
365 }
366
367 fn quote_currency(&self) -> Currency {
368 self.currency
369 }
370
371 fn base_currency(&self) -> Option<Currency> {
372 None
373 }
374
375 fn settlement_currency(&self) -> Currency {
376 self.currency
377 }
378
379 fn isin(&self) -> Option<Ustr> {
380 None
381 }
382
383 fn exchange(&self) -> Option<Ustr> {
384 None
385 }
386
387 fn option_kind(&self) -> Option<OptionKind> {
388 None
389 }
390
391 fn is_inverse(&self) -> bool {
392 false
393 }
394
395 fn price_precision(&self) -> u8 {
396 self.price_precision
397 }
398
399 fn size_precision(&self) -> u8 {
400 self.size_precision
401 }
402
403 fn price_increment(&self) -> Price {
404 self.price_increment
405 }
406
407 fn size_increment(&self) -> Quantity {
408 self.size_increment
409 }
410
411 fn multiplier(&self) -> Quantity {
412 Quantity::from(1)
413 }
414
415 fn lot_size(&self) -> Option<Quantity> {
416 Some(Quantity::from(1))
417 }
418
419 fn max_quantity(&self) -> Option<Quantity> {
420 self.max_quantity
421 }
422
423 fn min_quantity(&self) -> Option<Quantity> {
424 self.min_quantity
425 }
426
427 fn max_price(&self) -> Option<Price> {
428 self.max_price.or_else(|| {
429 self.uses_betfair_tick_scheme()
430 .then(|| BETFAIR_TICK_SCHEME.max_price())
431 })
432 }
433
434 fn min_price(&self) -> Option<Price> {
435 self.min_price.or_else(|| {
436 self.uses_betfair_tick_scheme()
437 .then(|| BETFAIR_TICK_SCHEME.min_price())
438 })
439 }
440
441 fn ts_event(&self) -> UnixNanos {
442 self.ts_event
443 }
444
445 fn ts_init(&self) -> UnixNanos {
446 self.ts_init
447 }
448
449 fn margin_init(&self) -> Decimal {
450 self.margin_init
451 }
452
453 fn margin_maint(&self) -> Decimal {
454 self.margin_maint
455 }
456
457 fn maker_fee(&self) -> Decimal {
458 self.maker_fee
459 }
460
461 fn taker_fee(&self) -> Decimal {
462 self.taker_fee
463 }
464
465 fn strike_price(&self) -> Option<Price> {
466 None
467 }
468
469 fn activation_ns(&self) -> Option<UnixNanos> {
470 Some(self.market_start_time)
471 }
472
473 fn expiration_ns(&self) -> Option<UnixNanos> {
474 None
475 }
476
477 fn max_notional(&self) -> Option<Money> {
478 self.max_notional
479 }
480
481 fn min_notional(&self) -> Option<Money> {
482 self.min_notional
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use rstest::rstest;
489 use rust_decimal_macros::dec;
490
491 use crate::{
492 enums::{AssetClass, InstrumentClass},
493 identifiers::InstrumentId,
494 instruments::{BettingInstrument, Instrument, stubs::*},
495 types::{Currency, Price, Quantity},
496 };
497
498 #[rstest]
499 fn test_trait_accessors(betting: BettingInstrument) {
500 assert_eq!(betting.asset_class(), AssetClass::Alternative);
501 assert_eq!(betting.instrument_class(), InstrumentClass::SportsBetting);
502 assert_eq!(betting.quote_currency(), Currency::GBP());
503 assert!(!betting.is_inverse());
504 assert_eq!(betting.price_precision(), 2);
505 assert_eq!(betting.size_precision(), 2);
506 assert_eq!(betting.price_increment(), Price::from("0.01"));
507 assert_eq!(betting.size_increment(), Quantity::from("0.01"));
508 assert_eq!(betting.margin_init(), dec!(1));
509 assert_eq!(betting.margin_maint(), dec!(1));
510 }
511
512 #[rstest]
513 fn test_new_checked_price_precision_mismatch() {
514 let result = BettingInstrument::new_checked(
515 InstrumentId::from("1-123.BETFAIR"),
516 "1-123".into(),
517 6423,
518 "Football".into(),
519 1,
520 "NFL".into(),
521 1,
522 "NFL".into(),
523 "GB".into(),
524 0.into(),
525 "ODDS".into(),
526 "1-123".into(),
527 "Winner".into(),
528 "SPECIAL".into(),
529 0.into(),
530 50214,
531 "Team".into(),
532 0.0,
533 Currency::GBP(),
534 4, 2,
536 Price::from("0.01"),
537 Quantity::from("0.01"),
538 None,
539 None,
540 None,
541 None,
542 None,
543 None,
544 None,
545 None,
546 None,
547 None,
548 None,
549 0.into(),
550 0.into(),
551 );
552 assert!(result.is_err());
553 }
554
555 #[rstest]
556 fn test_serialization_roundtrip(betting: BettingInstrument) {
557 let json = serde_json::to_string(&betting).unwrap();
558 let deserialized: BettingInstrument = serde_json::from_str(&json).unwrap();
559 assert_eq!(betting, deserialized);
560 }
561
562 #[rstest]
563 fn test_betfair_tick_scheme_navigation(mut betting: BettingInstrument) {
564 betting.max_price = None;
565 betting.min_price = None;
566
567 assert_eq!(betting.min_price(), Some(Price::from("1.01")));
568 assert_eq!(betting.max_price(), Some(Price::from("1000.00")));
569 assert_eq!(betting.next_ask_price(4.0, 1), Some(Price::from("4.10")));
570 assert_eq!(betting.next_bid_price(2.027, 2), Some(Price::from("1.99")));
571 assert_eq!(betting.next_bid_prices(1.102, 20).len(), 10);
572 assert_eq!(betting.next_ask_prices(1.102, 20).len(), 20);
573 }
574
575 #[rstest]
576 fn test_non_betfair_venue_no_tick_scheme(mut betting: BettingInstrument) {
577 betting.id = InstrumentId::from("1-123456789.SMARKETS");
578 betting.max_price = None;
579 betting.min_price = None;
580
581 assert!(betting.tick_scheme().is_none());
582 assert!(betting.min_price().is_none());
583 assert!(betting.max_price().is_none());
584 }
585}