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