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