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