1use std::{
19 fmt::Display,
20 ops::{Deref, DerefMut},
21};
22
23use ahash::AHashMap;
24use indexmap::IndexMap;
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28use crate::{
29 accounts::{
30 Account,
31 base::{self, BaseAccount},
32 },
33 enums::{InstrumentClass, OrderSide},
34 events::{AccountState, OrderFilled},
35 identifiers::InstrumentId,
36 instruments::{Instrument, InstrumentAny},
37 position::Position,
38 types::{AccountBalance, Currency, Money, Price, Quantity},
39};
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(
44 feature = "python",
45 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
46)]
47#[cfg_attr(
48 feature = "python",
49 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
50)]
51pub struct BettingAccount {
52 pub base: BaseAccount,
54 #[serde(skip, default)]
56 pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
57}
58
59impl BettingAccount {
60 #[must_use]
62 pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
63 Self {
64 base: BaseAccount::new(event, calculate_account_state),
65 balances_locked: AHashMap::new(),
66 }
67 }
68
69 #[must_use]
70 pub(crate) fn clone_without_events(&self) -> Self {
71 Self {
72 base: self.base.clone_without_events(),
73 balances_locked: self.balances_locked.clone(),
74 }
75 }
76
77 pub fn update_balance_locked(
85 &mut self,
86 instrument_id: InstrumentId,
87 locked: Money,
88 ) -> anyhow::Result<()> {
89 base::update_balance_locked(
90 &mut self.base.balances,
91 &mut self.balances_locked,
92 instrument_id,
93 locked,
94 )
95 }
96
97 pub fn clear_balance_locked(&mut self, instrument_id: InstrumentId) {
99 base::clear_balance_locked(
100 &mut self.base.balances,
101 &mut self.balances_locked,
102 instrument_id,
103 );
104 }
105
106 pub fn update_balances(&mut self, balances: &[AccountBalance]) -> anyhow::Result<()> {
112 for balance in balances {
113 if balance.total.is_negative() {
114 anyhow::bail!(
115 "Betting account balance would become negative: {} {} ({})",
116 balance.total.as_decimal(),
117 balance.currency.code,
118 self.id
119 );
120 }
121 }
122 self.base.update_balances(balances);
123 Ok(())
124 }
125
126 #[must_use]
127 pub const fn is_unleveraged(&self) -> bool {
128 true
129 }
130
131 #[must_use]
140 pub fn balance_impact(
141 &self,
142 instrument: &InstrumentAny,
143 quantity: Quantity,
144 price: Price,
145 order_side: OrderSide,
146 ) -> Money {
147 let currency = instrument.quote_currency();
148 let impact = match order_side {
149 OrderSide::Sell => -quantity.as_decimal(),
150 OrderSide::Buy => -(quantity.as_decimal() * (price.as_decimal() - Decimal::ONE)),
151 };
152 Money::from_decimal(impact, currency).expect("invalid betting balance impact")
153 }
154
155 pub fn recalculate_balance(&mut self, currency: Currency) {
157 base::recalculate_balance(&mut self.base.balances, &self.balances_locked, currency);
158 }
159}
160
161impl Account for BettingAccount {
162 impl_account_base_members!();
163
164 fn is_cash_account(&self) -> bool {
165 true
166 }
167
168 fn is_margin_account(&self) -> bool {
169 false
170 }
171
172 fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
173 self.check_event_account_id(&event)?;
174
175 for balance in &event.balances {
176 if balance.total.is_negative() {
177 anyhow::bail!(
178 "Cannot apply betting account state: balance would be negative {} {} ({})",
179 balance.total.as_decimal(),
180 balance.currency.code,
181 self.id
182 );
183 }
184 }
185
186 if event.is_reported {
187 self.balances_locked.clear();
188 }
189
190 self.base_apply(event);
191 Ok(())
192 }
193
194 fn calculate_balance_locked(
195 &self,
196 instrument: &InstrumentAny,
197 side: OrderSide,
198 quantity: Quantity,
199 price: Price,
200 use_quote_for_inverse: Option<bool>,
201 ) -> anyhow::Result<Money> {
202 anyhow::ensure!(
203 instrument.instrument_class() == InstrumentClass::SportsBetting,
204 "BettingAccount requires a sports betting instrument"
205 );
206 anyhow::ensure!(
207 use_quote_for_inverse != Some(true),
208 "`use_quote_for_inverse` is not applicable for betting accounts"
209 );
210
211 let locked = match side {
212 OrderSide::Sell => quantity.as_decimal(),
213 OrderSide::Buy => quantity.as_decimal() * (price.as_decimal() - Decimal::ONE),
214 };
215
216 Ok(Money::from_decimal(locked, instrument.quote_currency())?)
217 }
218
219 fn calculate_pnls(
220 &self,
221 instrument: &InstrumentAny,
222 fill: &OrderFilled,
223 position: Option<Position>,
224 ) -> anyhow::Result<Vec<Money>> {
225 anyhow::ensure!(
226 instrument.instrument_class() == InstrumentClass::SportsBetting,
227 "BettingAccount requires a sports betting instrument"
228 );
229
230 let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
231 let quote_currency = instrument.quote_currency();
232 let base_currency = instrument.base_currency();
233
234 let mut fill_qty = fill.last_qty;
235
236 if let Some(position) = position.as_ref()
237 && position.quantity.non_zero()
238 && position.entry != fill.order_side
239 {
240 fill_qty = fill.last_qty.min(position.quantity);
241 fill_qty.precision = fill.last_qty.precision;
242 }
243
244 let quote_pnl = Money::from_decimal(
245 fill.last_px.as_decimal() * fill_qty.as_decimal(),
246 quote_currency,
247 )?;
248
249 match fill.order_side {
250 OrderSide::Buy => {
251 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
252 pnls.insert(
253 base_currency_value,
254 Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
255 );
256 }
257 pnls.insert(quote_currency, -quote_pnl);
258 }
259 OrderSide::Sell => {
260 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
261 pnls.insert(
262 base_currency_value,
263 -Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
264 );
265 }
266 pnls.insert(quote_currency, quote_pnl);
267 }
268 }
269
270 Ok(pnls.into_values().collect())
271 }
272}
273
274impl Deref for BettingAccount {
275 type Target = BaseAccount;
276
277 fn deref(&self) -> &Self::Target {
278 &self.base
279 }
280}
281
282impl DerefMut for BettingAccount {
283 fn deref_mut(&mut self) -> &mut Self::Target {
284 &mut self.base
285 }
286}
287
288impl PartialEq for BettingAccount {
289 fn eq(&self, other: &Self) -> bool {
290 self.id == other.id
291 }
292}
293
294impl Eq for BettingAccount {}
295
296impl Display for BettingAccount {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 write!(
299 f,
300 "BettingAccount(id={}, type={}, base={})",
301 self.id,
302 self.account_type,
303 self.base_currency.map_or_else(
304 || "None".to_string(),
305 |base_currency| format!("{}", base_currency.code)
306 ),
307 )
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use indexmap::IndexMap;
314 use rstest::rstest;
315 use rust_decimal::Decimal;
316
317 use crate::{
318 accounts::{Account, BettingAccount, stubs::*},
319 enums::{AccountType, CurrencyType, LiquiditySide, OrderSide},
320 events::{AccountState, account::stubs::*},
321 identifiers::{AccountId, InstrumentId},
322 instruments::{Instrument, stubs::betting},
323 orders::stubs::TestOrderEventStubs,
324 position::Position,
325 types::{AccountBalance, Currency, Money, Price, Quantity},
326 };
327
328 #[rstest]
329 fn test_account_type_predicates(betting_account: BettingAccount) {
330 assert!(betting_account.is_unleveraged());
331 assert!(Account::is_cash_account(&betting_account));
332 assert!(!Account::is_margin_account(&betting_account));
333 }
334
335 #[rstest]
336 fn test_equality_compares_account_ids(betting_account_state: AccountState) {
337 let account = BettingAccount::new(betting_account_state.clone(), true);
338 let same = BettingAccount::new(betting_account_state.clone(), true);
339 let mut other_state = betting_account_state;
340 other_state.account_id = AccountId::from("OTHER-001");
341 let other = BettingAccount::new(other_state, true);
342
343 assert_eq!(account, same);
344 assert_ne!(account, other);
345 }
346
347 #[rstest]
348 fn test_display(betting_account: BettingAccount) {
349 assert_eq!(
350 format!("{betting_account}"),
351 "BettingAccount(id=SIM-001, type=BETTING, base=GBP)"
352 );
353 }
354
355 #[rstest]
356 fn test_instantiate_single_asset_betting_account(
357 betting_account: BettingAccount,
358 betting_account_state: AccountState,
359 ) {
360 assert_eq!(betting_account.id, AccountId::from("SIM-001"));
361 assert_eq!(betting_account.account_type, AccountType::Betting);
362 assert_eq!(betting_account.base_currency, Some(Currency::GBP()));
363 assert_eq!(
364 betting_account.last_event(),
365 Some(betting_account_state.clone())
366 );
367 assert_eq!(betting_account.events(), vec![betting_account_state]);
368 assert_eq!(betting_account.event_count(), 1);
369 assert_eq!(
370 betting_account.balance_total(None),
371 Some(Money::from("1000 GBP"))
372 );
373 assert_eq!(
374 betting_account.balance_free(None),
375 Some(Money::from("1000 GBP"))
376 );
377 assert_eq!(
378 betting_account.balance_locked(None),
379 Some(Money::from("0 GBP"))
380 );
381
382 let mut balances_total_expected = IndexMap::new();
383 balances_total_expected.insert(Currency::GBP(), Money::from("1000 GBP"));
384 assert_eq!(betting_account.balances_total(), balances_total_expected);
385 }
386
387 #[rstest]
388 fn test_apply_given_new_state_event_updates_correctly(
389 mut betting_account: BettingAccount,
390 betting_account_state: AccountState,
391 betting_account_state_changed: AccountState,
392 ) {
393 betting_account
394 .apply(betting_account_state_changed.clone())
395 .unwrap();
396
397 assert_eq!(
398 betting_account.last_event(),
399 Some(betting_account_state_changed.clone())
400 );
401 assert_eq!(
402 betting_account.events,
403 vec![betting_account_state, betting_account_state_changed]
404 );
405 assert_eq!(betting_account.event_count(), 2);
406 assert_eq!(
407 betting_account.balance_total(None),
408 Some(Money::from("900 GBP"))
409 );
410 assert_eq!(
411 betting_account.balance_free(None),
412 Some(Money::from("850 GBP"))
413 );
414 assert_eq!(
415 betting_account.balance_locked(None),
416 Some(Money::from("50 GBP"))
417 );
418 }
419
420 #[rstest]
421 #[case(OrderSide::Sell, "1.60", "10", "10 GBP")]
422 #[case(OrderSide::Sell, "2.00", "10", "10 GBP")]
423 #[case(OrderSide::Sell, "10.00", "20", "20 GBP")]
424 #[case(OrderSide::Buy, "1.25", "10", "2.5 GBP")]
425 #[case(OrderSide::Buy, "2.00", "10", "10 GBP")]
426 #[case(OrderSide::Buy, "10.00", "10", "90 GBP")]
427 fn test_calculate_balance_locked(
428 betting_account: BettingAccount,
429 betting: crate::instruments::BettingInstrument,
430 #[case] side: OrderSide,
431 #[case] price: &str,
432 #[case] quantity: &str,
433 #[case] expected: &str,
434 ) {
435 let result = betting_account
436 .calculate_balance_locked(
437 &betting.into_any(),
438 side,
439 Quantity::from(quantity),
440 Price::from(price),
441 None,
442 )
443 .unwrap();
444 assert_eq!(result, Money::from(expected));
445 }
446
447 #[rstest]
448 fn test_calculate_pnls_single_currency_account(
449 betting_account: BettingAccount,
450 betting: crate::instruments::BettingInstrument,
451 ) {
452 let order = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
453 .instrument_id(betting.id())
454 .side(OrderSide::Buy)
455 .quantity(Quantity::from("100"))
456 .build();
457 let betting_any = betting.into_any();
458 let fill = TestOrderEventStubs::filled(
459 &order,
460 &betting_any,
461 None,
462 None,
463 Some(Price::from("0.8")),
464 None,
465 None,
466 None,
467 None,
468 Some(AccountId::from("SIM-001")),
469 );
470 let position = Position::new(&betting_any, fill.clone().into());
471 let fill_owned: crate::events::OrderFilled = fill.into();
472
473 let result = betting_account
474 .calculate_pnls(&betting_any, &fill_owned, Some(position))
475 .unwrap();
476
477 assert_eq!(result, vec![Money::from("-80 GBP")]);
478 }
479
480 #[rstest]
481 fn test_calculate_pnls_does_not_clamp_when_fill_extends_position(
482 betting_account: BettingAccount,
483 betting: crate::instruments::BettingInstrument,
484 ) {
485 let order1 = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
486 .instrument_id(betting.id())
487 .side(OrderSide::Buy)
488 .quantity(Quantity::from("100"))
489 .build();
490 let betting_any = betting.clone().into_any();
491 let fill1 = TestOrderEventStubs::filled(
492 &order1,
493 &betting_any,
494 None,
495 None,
496 Some(Price::from("0.5")),
497 None,
498 None,
499 None,
500 None,
501 Some(AccountId::from("SIM-001")),
502 );
503
504 let order2 = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
505 .instrument_id(betting.id())
506 .side(OrderSide::Buy)
507 .quantity(Quantity::from("200"))
508 .build();
509 let fill2 = TestOrderEventStubs::filled(
510 &order2,
511 &betting_any,
512 None,
513 None,
514 Some(Price::from("0.8")),
515 None,
516 None,
517 None,
518 None,
519 Some(AccountId::from("SIM-001")),
520 );
521
522 let position = Position::new(&betting_any, fill1.into());
523 let fill2_owned: crate::events::OrderFilled = fill2.into();
524 let result = betting_account
525 .calculate_pnls(&betting_any, &fill2_owned, Some(position))
526 .unwrap();
527
528 assert_eq!(result, vec![Money::from("-160 GBP")]);
529 }
530
531 #[rstest]
532 fn test_calculate_pnls_partially_closed(
533 betting_account: BettingAccount,
534 betting: crate::instruments::BettingInstrument,
535 ) {
536 let order1 = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
537 .instrument_id(betting.id())
538 .side(OrderSide::Buy)
539 .quantity(Quantity::from("100"))
540 .build();
541 let betting_any = betting.clone().into_any();
542 let fill1 = TestOrderEventStubs::filled(
543 &order1,
544 &betting_any,
545 None,
546 None,
547 Some(Price::from("0.5")),
548 None,
549 None,
550 None,
551 None,
552 Some(AccountId::from("SIM-001")),
553 );
554
555 let order2 = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
556 .instrument_id(betting.id())
557 .side(OrderSide::Sell)
558 .quantity(Quantity::from("50"))
559 .build();
560 let fill2 = TestOrderEventStubs::filled(
561 &order2,
562 &betting_any,
563 None,
564 None,
565 Some(Price::from("0.8")),
566 None,
567 None,
568 None,
569 None,
570 Some(AccountId::from("SIM-001")),
571 );
572
573 let position = Position::new(&betting_any, fill1.into());
574 let fill2_owned: crate::events::OrderFilled = fill2.into();
575 let result = betting_account
576 .calculate_pnls(&betting_any, &fill2_owned, Some(position))
577 .unwrap();
578
579 assert_eq!(result, vec![Money::from("40 GBP")]);
580 }
581
582 #[rstest]
583 fn test_calculate_commission_invalid_liquidity_side_raises(
584 betting_account: BettingAccount,
585 betting: crate::instruments::BettingInstrument,
586 ) {
587 let result = betting_account.calculate_commission(
588 &betting.into_any(),
589 Quantity::from("1"),
590 Price::from("1"),
591 LiquiditySide::NoLiquiditySide,
592 None,
593 );
594 assert!(
595 result
596 .unwrap_err()
597 .to_string()
598 .contains("Invalid `LiquiditySide`: NO_LIQUIDITY_SIDE")
599 );
600 }
601
602 #[rstest]
603 #[case(OrderSide::Buy, "5.0", "100", "-400 GBP")]
604 #[case(OrderSide::Buy, "1.5", "100", "-50 GBP")]
605 #[case(OrderSide::Sell, "5.0", "100", "-100 GBP")]
606 #[case(OrderSide::Sell, "10.0", "100", "-100 GBP")]
607 fn test_balance_impact(
608 betting_account: BettingAccount,
609 betting: crate::instruments::BettingInstrument,
610 #[case] side: OrderSide,
611 #[case] price: &str,
612 #[case] quantity: &str,
613 #[case] expected: &str,
614 ) {
615 let impact = betting_account.balance_impact(
616 &betting.into_any(),
617 Quantity::from(quantity),
618 Price::from(price),
619 side,
620 );
621
622 assert_eq!(impact, Money::from(expected));
623 }
624
625 #[rstest]
626 fn test_apply_rejects_negative_balance(mut betting_account: BettingAccount) {
627 let negative_state = AccountState::new(
628 AccountId::from("SIM-001"),
629 AccountType::Betting,
630 vec![AccountBalance::new(
631 Money::from("-50 GBP"),
632 Money::from("0 GBP"),
633 Money::from("-50 GBP"),
634 )],
635 vec![],
636 false,
637 crate::identifiers::stubs::uuid4(),
638 0.into(),
639 0.into(),
640 Some(Currency::GBP()),
641 );
642
643 let result = betting_account.apply(negative_state);
644 assert!(result.is_err());
645 assert!(
646 result
647 .unwrap_err()
648 .to_string()
649 .contains("balance would be negative")
650 );
651 }
652
653 #[rstest]
654 fn test_update_balances_rejects_negative_total(mut betting_account: BettingAccount) {
655 let result = betting_account.update_balances(&[AccountBalance::new(
656 Money::from("-10 GBP"),
657 Money::from("0 GBP"),
658 Money::from("-10 GBP"),
659 )]);
660
661 assert!(result.is_err());
662 }
663
664 #[rstest]
665 fn test_recalculate_balance_clamps_locked_to_total(mut betting_account: BettingAccount) {
666 let instrument_id =
667 crate::identifiers::InstrumentId::from("BETFAIR-1.2345678-12345678-0.0.NONE");
668
669 betting_account
670 .update_balance_locked(instrument_id, Money::from("1500 GBP"))
671 .unwrap();
672
673 let balance = betting_account.balance(Some(Currency::GBP())).unwrap();
674 assert_eq!(balance.locked, Money::from("1000 GBP"));
675 assert_eq!(balance.free, Money::from("0 GBP"));
676 assert_eq!(balance.total, Money::from("1000 GBP"));
677 }
678
679 #[rstest]
680 fn test_update_balance_locked_precision_mismatch_preserves_state(
681 mut betting_account: BettingAccount,
682 ) {
683 let instrument_id = InstrumentId::from("BETFAIR-1.2345678-12345678-0.0.NONE");
684 let gbp = Currency::GBP();
685 betting_account
686 .update_balance_locked(instrument_id, Money::from("100 GBP"))
687 .unwrap();
688 let balance_before = *betting_account.balance(Some(gbp)).unwrap();
689 let locks_before = betting_account.balances_locked.clone();
690 let mismatched_gbp = Currency::new(
691 "GBP",
692 gbp.precision + 1,
693 826,
694 "Pound Sterling",
695 CurrencyType::Fiat,
696 );
697 let locked = Money::from_decimal(Decimal::from(50), mismatched_gbp).unwrap();
698
699 let error = betting_account
700 .update_balance_locked(instrument_id, locked)
701 .unwrap_err();
702
703 assert_eq!(
704 error.to_string(),
705 "Cannot update GBP reservation: precision 3 differed from balance precision 2"
706 );
707 assert_eq!(betting_account.balance(Some(gbp)), Some(&balance_before));
708 assert_eq!(betting_account.balances_locked, locks_before);
709 }
710
711 #[rstest]
712 fn test_calculate_pnls_sell_fill(
713 betting_account: BettingAccount,
714 betting: crate::instruments::BettingInstrument,
715 ) {
716 let order = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
717 .instrument_id(betting.id())
718 .side(OrderSide::Sell)
719 .quantity(Quantity::from("100"))
720 .build();
721 let betting_any = betting.into_any();
722 let fill = TestOrderEventStubs::filled(
723 &order,
724 &betting_any,
725 None,
726 None,
727 Some(Price::from("0.8")),
728 None,
729 None,
730 None,
731 None,
732 Some(AccountId::from("SIM-001")),
733 );
734 let position = Position::new(&betting_any, fill.clone().into());
735 let fill_owned: crate::events::OrderFilled = fill.into();
736
737 let result = betting_account
738 .calculate_pnls(&betting_any, &fill_owned, Some(position))
739 .unwrap();
740
741 assert_eq!(result, vec![Money::from("80 GBP")]);
742 }
743
744 #[rstest]
745 fn test_calculate_balance_locked_rejects_non_betting_instrument(
746 betting_account: BettingAccount,
747 ) {
748 let audusd = crate::instruments::stubs::audusd_sim();
749 let result = betting_account.calculate_balance_locked(
750 &audusd.into(),
751 OrderSide::Buy,
752 Quantity::from("100"),
753 Price::from("1.5"),
754 None,
755 );
756
757 assert!(result.is_err());
758 assert!(result.unwrap_err().to_string().contains("sports betting"));
759 }
760
761 #[rstest]
762 fn test_calculate_balance_locked_rejects_use_quote_for_inverse(
763 betting_account: BettingAccount,
764 betting: crate::instruments::BettingInstrument,
765 ) {
766 let result = betting_account.calculate_balance_locked(
767 &betting.into_any(),
768 OrderSide::Buy,
769 Quantity::from("100"),
770 Price::from("1.5"),
771 Some(true),
772 );
773
774 assert_eq!(
775 result.unwrap_err().to_string(),
776 "`use_quote_for_inverse` is not applicable for betting accounts"
777 );
778 }
779
780 #[rstest]
781 fn test_calculate_pnls_rejects_non_betting_instrument(betting_account: BettingAccount) {
782 let audusd = crate::instruments::stubs::audusd_sim();
783 let audusd_any = audusd.into_any();
784 let order = crate::orders::builder::OrderTestBuilder::new(crate::enums::OrderType::Market)
785 .instrument_id(audusd_any.id())
786 .side(OrderSide::Buy)
787 .quantity(Quantity::from("100000"))
788 .build();
789 let fill: crate::events::OrderFilled = TestOrderEventStubs::filled(
790 &order,
791 &audusd_any,
792 None,
793 None,
794 Some(Price::from("0.8")),
795 None,
796 None,
797 None,
798 None,
799 Some(AccountId::from("SIM-001")),
800 )
801 .into();
802
803 let result = betting_account.calculate_pnls(&audusd_any, &fill, None);
804
805 assert_eq!(
806 result.unwrap_err().to_string(),
807 "BettingAccount requires a sports betting instrument"
808 );
809 }
810}