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 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/// Represents a betting account that stakes on sports betting markets.
43#[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    /// The account state shared by every account type.
54    pub base: BaseAccount,
55    /// Per-(instrument, currency) locked balances (transient, not persisted).
56    #[serde(skip, default)]
57    pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
58}
59
60impl BettingAccount {
61    /// Creates a new [`BettingAccount`] instance.
62    #[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    /// Updates the locked balance for the given instrument and currency.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if `locked` is negative, its precision differs from the balance
83    /// precision, or the reservations cannot produce a valid balance. The balance and
84    /// reservations are left unchanged when an error is returned.
85    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    /// Clears all locked balances for the given instrument ID.
99    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    /// Updates the account balances, rejecting negative totals.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if any balance has a negative total.
112    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    /// Returns the balance impact for a betting order.
133    ///
134    /// For `Sell` (back) the impact is the negative stake (quantity).
135    /// For `Buy` (lay) the impact is the negative liability (quantity * (price - 1)).
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the impact cannot be represented in the quote currency.
140    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    /// Recalculates the account balance for the specified currency based on per-instrument locks.
168    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}