Skip to main content

nautilus_model/accounts/
betting.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! A betting account with sports-betting specific balance locking and PnL rules.
17
18use 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/// Represents a betting account that stakes on sports betting markets.
42#[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    /// The account state shared by every account type.
53    pub base: BaseAccount,
54    /// Per-(instrument, currency) locked balances (transient, not persisted).
55    #[serde(skip, default)]
56    pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
57}
58
59impl BettingAccount {
60    /// Creates a new [`BettingAccount`] instance.
61    #[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    /// Updates the locked balance for the given instrument and currency.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if `locked` is negative, its precision differs from the balance
82    /// precision, or the reservations cannot produce a valid balance. The balance and
83    /// reservations are left unchanged when an error is returned.
84    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    /// Clears all locked balances for the given instrument ID.
98    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    /// Updates the account balances, rejecting negative totals.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if any balance has a negative total.
111    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    /// Returns the balance impact for a betting order.
132    ///
133    /// For `Sell` (back) the impact is the negative stake (quantity).
134    /// For `Buy` (lay) the impact is the negative liability (quantity * (price - 1)).
135    ///
136    /// # Panics
137    ///
138    /// Panics if the impact cannot be represented in the quote currency.
139    #[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    /// Recalculates the account balance for the specified currency based on per-instrument locks.
156    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}