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