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