Skip to main content

nautilus_model/accounts/
cash.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 cash account that cannot hold leveraged positions.
17//!
18//! # Balance locking
19//!
20//! The account tracks locked balances per `(InstrumentId, Currency)` to support
21//! instruments that lock different currencies depending on order side:
22//! - BUY orders lock quote currency (cost of purchase).
23//! - SELL orders lock base currency (assets being sold).
24//!
25//! Callers must clear all existing locks via [`CashAccount::clear_balance_locked`]
26//! before applying new locks. This prevents stale currency entries when order
27//! compositions change.
28//!
29//! # Graceful degradation
30//!
31//! When total locked exceeds total balance (e.g., due to venue/client state latency),
32//! the account clamps locked to total rather than raising an error. This yields zero
33//! free balance, preventing new orders while avoiding crashes in live trading.
34
35use std::{
36    fmt::Display,
37    ops::{Deref, DerefMut},
38};
39
40use ahash::AHashMap;
41use indexmap::IndexMap;
42use serde::{Deserialize, Serialize};
43
44use crate::{
45    accounts::{Account, base::BaseAccount},
46    enums::{AccountType, LiquiditySide, OrderSide},
47    events::{AccountState, OrderFilled},
48    identifiers::{AccountId, InstrumentId},
49    instruments::InstrumentAny,
50    position::Position,
51    types::{AccountBalance, Currency, Money, Price, Quantity, money::MoneyRaw},
52};
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[cfg_attr(
56    feature = "python",
57    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
58)]
59#[cfg_attr(
60    feature = "python",
61    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
62)]
63pub struct CashAccount {
64    pub base: BaseAccount,
65    pub allow_borrowing: bool,
66    /// Per-(instrument, currency) locked balances (transient, not persisted).
67    #[serde(skip, default)]
68    pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
69}
70
71impl CashAccount {
72    /// Creates a new [`CashAccount`] instance.
73    #[must_use]
74    pub fn new(event: AccountState, calculate_account_state: bool, allow_borrowing: bool) -> Self {
75        Self {
76            base: BaseAccount::new(event, calculate_account_state),
77            allow_borrowing,
78            balances_locked: AHashMap::new(),
79        }
80    }
81
82    /// Updates the locked balance for the given instrument and currency.
83    ///
84    /// # Panics
85    ///
86    /// Panics if `locked` is negative.
87    pub fn update_balance_locked(&mut self, instrument_id: InstrumentId, locked: Money) {
88        assert!(locked.raw >= 0, "locked balance was negative: {locked}");
89        let currency = locked.currency;
90        self.balances_locked
91            .insert((instrument_id, currency), locked);
92        self.recalculate_balance(currency);
93    }
94
95    /// Clears all locked balances for the given instrument ID.
96    pub fn clear_balance_locked(&mut self, instrument_id: InstrumentId) {
97        let currencies_to_recalc: Vec<Currency> = self
98            .balances_locked
99            .keys()
100            .filter(|(id, _)| *id == instrument_id)
101            .map(|(_, currency)| *currency)
102            .collect();
103
104        for currency in &currencies_to_recalc {
105            self.balances_locked.remove(&(instrument_id, *currency));
106        }
107
108        for currency in currencies_to_recalc {
109            self.recalculate_balance(currency);
110        }
111    }
112
113    /// Updates the account balances, enforcing borrowing constraints.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if `allow_borrowing` is false and any balance has a negative total.
118    ///
119    /// TODO: Force stop backtest engine on error (like Python's `set_backtest_force_stop`)
120    pub fn update_balances(&mut self, balances: &[AccountBalance]) -> anyhow::Result<()> {
121        if !self.allow_borrowing {
122            for balance in balances {
123                if balance.total.raw < 0 {
124                    anyhow::bail!(
125                        "Cash account balance would become negative: {} {} (borrowing not allowed for {})",
126                        balance.total.as_decimal(),
127                        balance.currency.code,
128                        self.id
129                    );
130                }
131            }
132        }
133        self.base.update_balances(balances);
134        Ok(())
135    }
136
137    #[must_use]
138    pub fn is_cash_account(&self) -> bool {
139        self.account_type == AccountType::Cash
140    }
141
142    #[must_use]
143    pub fn is_margin_account(&self) -> bool {
144        self.account_type == AccountType::Margin
145    }
146
147    #[must_use]
148    pub const fn is_unleveraged(&self) -> bool {
149        true
150    }
151
152    /// Recalculates the account balance for the specified currency based on per-instrument locks.
153    ///
154    /// Sums all per-instrument locked amounts for the currency and updates the balance.
155    /// If the total locked exceeds the total balance, clamps to total (free = 0).
156    ///
157    pub fn recalculate_balance(&mut self, currency: Currency) {
158        let current_balance = if let Some(balance) = self.balances.get(&currency) {
159            *balance
160        } else {
161            log::debug!("Cannot recalculate balance when no current balance for {currency}");
162            return;
163        };
164
165        let total_locked_raw: MoneyRaw = self
166            .balances_locked
167            .values()
168            .filter(|locked| locked.currency == currency)
169            .map(|locked| locked.raw)
170            .fold(0, |acc, raw| acc.saturating_add(raw));
171
172        let total_raw = current_balance.total.raw;
173
174        // Clamp locked to total if it exceeds and total is non-negative.
175        // When total is negative (borrowing), keep locked as-is and allow free to be negative.
176        let (locked_raw, free_raw) = if total_locked_raw > total_raw && total_raw >= 0 {
177            (total_raw, 0)
178        } else {
179            (total_locked_raw, total_raw - total_locked_raw)
180        };
181
182        let new_balance = AccountBalance::new(
183            current_balance.total,
184            Money::from_raw(locked_raw, currency),
185            Money::from_raw(free_raw, currency),
186        );
187
188        self.balances.insert(currency, new_balance);
189    }
190}
191
192impl Account for CashAccount {
193    fn id(&self) -> AccountId {
194        self.id
195    }
196
197    fn account_type(&self) -> AccountType {
198        self.account_type
199    }
200
201    fn base_currency(&self) -> Option<Currency> {
202        self.base_currency
203    }
204
205    fn is_cash_account(&self) -> bool {
206        self.account_type == AccountType::Cash
207    }
208
209    fn is_margin_account(&self) -> bool {
210        self.account_type == AccountType::Margin
211    }
212
213    fn calculated_account_state(&self) -> bool {
214        self.calculate_account_state
215    }
216
217    fn balance_total(&self, currency: Option<Currency>) -> Option<Money> {
218        self.base_balance_total(currency)
219    }
220
221    fn balances_total(&self) -> IndexMap<Currency, Money> {
222        self.base_balances_total()
223    }
224
225    fn balance_free(&self, currency: Option<Currency>) -> Option<Money> {
226        self.base_balance_free(currency)
227    }
228
229    fn balances_free(&self) -> IndexMap<Currency, Money> {
230        self.base_balances_free()
231    }
232
233    fn balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
234        self.base_balance_locked(currency)
235    }
236
237    fn balances_locked(&self) -> IndexMap<Currency, Money> {
238        self.base_balances_locked()
239    }
240
241    fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
242        self.base_balance(currency)
243    }
244
245    fn last_event(&self) -> Option<AccountState> {
246        self.base_last_event()
247    }
248
249    fn events(&self) -> Vec<AccountState> {
250        self.events.clone()
251    }
252
253    fn event_count(&self) -> usize {
254        self.events.len()
255    }
256
257    fn currencies(&self) -> Vec<Currency> {
258        self.balances.keys().copied().collect()
259    }
260
261    fn starting_balances(&self) -> IndexMap<Currency, Money> {
262        self.balances_starting.clone()
263    }
264
265    fn balances(&self) -> IndexMap<Currency, AccountBalance> {
266        self.balances.clone()
267    }
268
269    fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
270        if !self.allow_borrowing {
271            for balance in &event.balances {
272                if balance.total.raw < 0 {
273                    anyhow::bail!(
274                        "Cannot apply account state: balance would be negative {} {} \
275                        (borrowing not allowed for {})",
276                        balance.total.as_decimal(),
277                        balance.currency.code,
278                        self.id
279                    );
280                }
281            }
282        }
283
284        // Only clear locks when the venue reports a fresh balance snapshot
285        if event.is_reported && !event.balances.is_empty() {
286            self.balances_locked.clear();
287        }
288
289        self.base_apply(event);
290        Ok(())
291    }
292
293    fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
294        self.base.base_purge_account_events(ts_now, lookback_secs);
295    }
296
297    fn calculate_balance_locked(
298        &mut self,
299        instrument: &InstrumentAny,
300        side: OrderSide,
301        quantity: Quantity,
302        price: Price,
303        use_quote_for_inverse: Option<bool>,
304    ) -> anyhow::Result<Money> {
305        self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
306    }
307
308    fn calculate_pnls(
309        &self,
310        instrument: &InstrumentAny,
311        fill: &OrderFilled,
312        position: Option<Position>,
313    ) -> anyhow::Result<Vec<Money>> {
314        self.base_calculate_pnls(instrument, fill, position)
315    }
316
317    fn calculate_commission(
318        &self,
319        instrument: &InstrumentAny,
320        last_qty: Quantity,
321        last_px: Price,
322        liquidity_side: LiquiditySide,
323        use_quote_for_inverse: Option<bool>,
324    ) -> anyhow::Result<Money> {
325        self.base_calculate_commission(
326            instrument,
327            last_qty,
328            last_px,
329            liquidity_side,
330            use_quote_for_inverse,
331        )
332    }
333}
334
335impl Deref for CashAccount {
336    type Target = BaseAccount;
337
338    fn deref(&self) -> &Self::Target {
339        &self.base
340    }
341}
342
343impl DerefMut for CashAccount {
344    fn deref_mut(&mut self) -> &mut Self::Target {
345        &mut self.base
346    }
347}
348
349impl PartialEq for CashAccount {
350    fn eq(&self, other: &Self) -> bool {
351        self.id == other.id
352    }
353}
354
355impl Eq for CashAccount {}
356
357impl Display for CashAccount {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        write!(
360            f,
361            "CashAccount(id={}, type={}, base={})",
362            self.id,
363            self.account_type,
364            self.base_currency.map_or_else(
365                || "None".to_string(),
366                |base_currency| format!("{}", base_currency.code)
367            ),
368        )
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use ahash::AHashSet;
375    use indexmap::IndexMap;
376    use rstest::rstest;
377
378    use crate::{
379        accounts::{Account, CashAccount, stubs::*},
380        enums::{AccountType, LiquiditySide, OrderSide, OrderType},
381        events::{AccountState, account::stubs::*},
382        identifiers::{AccountId, InstrumentId, position_id::PositionId, stubs::uuid4},
383        instruments::{
384            CryptoFuture, CryptoPerpetual, CurrencyPair, Equity, Instrument, InstrumentAny,
385            stubs::*,
386        },
387        orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
388        position::Position,
389        types::{AccountBalance, Currency, Money, Price, Quantity},
390    };
391
392    #[rstest]
393    fn test_display(cash_account: CashAccount) {
394        assert_eq!(
395            format!("{cash_account}"),
396            "CashAccount(id=SIM-001, type=CASH, base=USD)"
397        );
398    }
399
400    #[rstest]
401    fn test_calculated_account_state_returns_field_value(cash_account_state: AccountState) {
402        assert!(
403            CashAccount::new(cash_account_state.clone(), true, false).calculated_account_state()
404        );
405        assert!(!CashAccount::new(cash_account_state, false, false).calculated_account_state());
406    }
407
408    #[rstest]
409    fn test_instantiate_single_asset_cash_account(
410        cash_account: CashAccount,
411        cash_account_state: AccountState,
412    ) {
413        assert_eq!(cash_account.id, AccountId::from("SIM-001"));
414        assert_eq!(cash_account.account_type, AccountType::Cash);
415        assert_eq!(cash_account.base_currency, Some(Currency::from("USD")));
416        assert_eq!(cash_account.last_event(), Some(cash_account_state.clone()));
417        assert_eq!(cash_account.events(), vec![cash_account_state]);
418        assert_eq!(cash_account.event_count(), 1);
419        assert_eq!(
420            cash_account.balance_total(None),
421            Some(Money::from("1525000 USD"))
422        );
423        assert_eq!(
424            cash_account.balance_free(None),
425            Some(Money::from("1500000 USD"))
426        );
427        assert_eq!(
428            cash_account.balance_locked(None),
429            Some(Money::from("25000 USD"))
430        );
431        let mut balances_total_expected = IndexMap::new();
432        balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
433        assert_eq!(cash_account.balances_total(), balances_total_expected);
434        let mut balances_free_expected = IndexMap::new();
435        balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
436        assert_eq!(cash_account.balances_free(), balances_free_expected);
437        let mut balances_locked_expected = IndexMap::new();
438        balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
439        assert_eq!(cash_account.balances_locked(), balances_locked_expected);
440    }
441
442    #[rstest]
443    fn test_instantiate_multi_asset_cash_account(
444        cash_account_multi: CashAccount,
445        cash_account_state_multi: AccountState,
446    ) {
447        assert_eq!(cash_account_multi.id, AccountId::from("SIM-001"));
448        assert_eq!(cash_account_multi.account_type, AccountType::Cash);
449        assert_eq!(
450            cash_account_multi.last_event(),
451            Some(cash_account_state_multi.clone())
452        );
453        assert_eq!(cash_account_state_multi.base_currency, None);
454        assert_eq!(cash_account_multi.events(), vec![cash_account_state_multi]);
455        assert_eq!(cash_account_multi.event_count(), 1);
456        assert_eq!(
457            cash_account_multi.balance_total(Some(Currency::BTC())),
458            Some(Money::from("10 BTC"))
459        );
460        assert_eq!(
461            cash_account_multi.balance_total(Some(Currency::ETH())),
462            Some(Money::from("20 ETH"))
463        );
464        assert_eq!(
465            cash_account_multi.balance_free(Some(Currency::BTC())),
466            Some(Money::from("10 BTC"))
467        );
468        assert_eq!(
469            cash_account_multi.balance_free(Some(Currency::ETH())),
470            Some(Money::from("20 ETH"))
471        );
472        assert_eq!(
473            cash_account_multi.balance_locked(Some(Currency::BTC())),
474            Some(Money::from("0 BTC"))
475        );
476        assert_eq!(
477            cash_account_multi.balance_locked(Some(Currency::ETH())),
478            Some(Money::from("0 ETH"))
479        );
480        let mut balances_total_expected = IndexMap::new();
481        balances_total_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
482        balances_total_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
483        assert_eq!(cash_account_multi.balances_total(), balances_total_expected);
484        let mut balances_free_expected = IndexMap::new();
485        balances_free_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
486        balances_free_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
487        assert_eq!(cash_account_multi.balances_free(), balances_free_expected);
488        let mut balances_locked_expected = IndexMap::new();
489        balances_locked_expected.insert(Currency::from("BTC"), Money::from("0 BTC"));
490        balances_locked_expected.insert(Currency::from("ETH"), Money::from("0 ETH"));
491        assert_eq!(
492            cash_account_multi.balances_locked(),
493            balances_locked_expected
494        );
495    }
496
497    #[rstest]
498    fn test_cash_account_balances_preserve_insertion_order(cash_account_multi: CashAccount) {
499        // Locks in IndexMap iteration order for BaseAccount.balances:
500        // currencies appear in the same order as the AccountState.balances
501        // Vec they were initialised from. Drives the deterministic ordering
502        // of regenerated AccountState events in portfolio::manager.
503        let keys: Vec<Currency> = cash_account_multi.balances().keys().copied().collect();
504        assert_eq!(keys, vec![Currency::from("BTC"), Currency::from("ETH")]);
505
506        let totals: Vec<(Currency, Money)> =
507            cash_account_multi.balances_total().into_iter().collect();
508        assert_eq!(
509            totals,
510            vec![
511                (Currency::from("BTC"), Money::from("10 BTC")),
512                (Currency::from("ETH"), Money::from("20 ETH")),
513            ]
514        );
515    }
516
517    #[rstest]
518    fn test_apply_given_new_state_event_updates_correctly(
519        mut cash_account_multi: CashAccount,
520        cash_account_state_multi: AccountState,
521        cash_account_state_multi_changed_btc: AccountState,
522    ) {
523        // Apply second account event
524        cash_account_multi
525            .apply(cash_account_state_multi_changed_btc.clone())
526            .unwrap();
527        assert_eq!(
528            cash_account_multi.last_event(),
529            Some(cash_account_state_multi_changed_btc.clone())
530        );
531        assert_eq!(
532            cash_account_multi.events,
533            vec![
534                cash_account_state_multi,
535                cash_account_state_multi_changed_btc
536            ]
537        );
538        assert_eq!(cash_account_multi.event_count(), 2);
539        assert_eq!(
540            cash_account_multi.balance_total(Some(Currency::BTC())),
541            Some(Money::from("9 BTC"))
542        );
543        assert_eq!(
544            cash_account_multi.balance_free(Some(Currency::BTC())),
545            Some(Money::from("8.5 BTC"))
546        );
547        assert_eq!(
548            cash_account_multi.balance_locked(Some(Currency::BTC())),
549            Some(Money::from("0.5 BTC"))
550        );
551        assert_eq!(
552            cash_account_multi.balance_total(Some(Currency::ETH())),
553            Some(Money::from("20 ETH"))
554        );
555        assert_eq!(
556            cash_account_multi.balance_free(Some(Currency::ETH())),
557            Some(Money::from("20 ETH"))
558        );
559        assert_eq!(
560            cash_account_multi.balance_locked(Some(Currency::ETH())),
561            Some(Money::from("0 ETH"))
562        );
563    }
564
565    #[rstest]
566    fn test_calculate_balance_locked_buy(
567        mut cash_account_million_usd: CashAccount,
568        audusd_sim: CurrencyPair,
569    ) {
570        let balance_locked = cash_account_million_usd
571            .calculate_balance_locked(
572                &audusd_sim.into_any(),
573                OrderSide::Buy,
574                Quantity::from("1000000"),
575                Price::from("0.8"),
576                None,
577            )
578            .unwrap();
579        assert_eq!(balance_locked, Money::from("800000 USD"));
580    }
581
582    #[rstest]
583    fn test_calculate_balance_locked_buy_quanto_uses_quote_currency(
584        mut cash_account_million_usd: CashAccount,
585        ethbtc_quanto: CryptoFuture,
586    ) {
587        let balance_locked = cash_account_million_usd
588            .calculate_balance_locked(
589                &ethbtc_quanto.into_any(),
590                OrderSide::Buy,
591                Quantity::from("5"),
592                Price::from("0.036"),
593                None,
594            )
595            .unwrap();
596        assert_eq!(balance_locked, Money::from("0.18 BTC"));
597    }
598
599    #[rstest]
600    #[case(false, Money::from("0.002 BTC"))]
601    #[case(true, Money::from("100 USD"))]
602    fn test_calculate_balance_locked_buy_inverse_respects_quote_flag(
603        #[case] use_quote_for_inverse: bool,
604        #[case] expected: Money,
605        mut cash_account_million_usd: CashAccount,
606        xbtusd_inverse_perp: CryptoPerpetual,
607    ) {
608        let balance_locked = cash_account_million_usd
609            .calculate_balance_locked(
610                &xbtusd_inverse_perp.into_any(),
611                OrderSide::Buy,
612                Quantity::from("100"),
613                Price::from("50000"),
614                Some(use_quote_for_inverse),
615            )
616            .unwrap();
617        assert_eq!(balance_locked, expected);
618    }
619
620    #[rstest]
621    fn test_calculate_balance_locked_sell(
622        mut cash_account_million_usd: CashAccount,
623        audusd_sim: CurrencyPair,
624    ) {
625        let balance_locked = cash_account_million_usd
626            .calculate_balance_locked(
627                &audusd_sim.into_any(),
628                OrderSide::Sell,
629                Quantity::from("1000000"),
630                Price::from("0.8"),
631                None,
632            )
633            .unwrap();
634        assert_eq!(balance_locked, Money::from("1000000 AUD"));
635    }
636
637    #[rstest]
638    fn test_calculate_balance_locked_sell_no_base_currency(
639        mut cash_account_million_usd: CashAccount,
640        equity_aapl: Equity,
641    ) {
642        let balance_locked = cash_account_million_usd
643            .calculate_balance_locked(
644                &equity_aapl.into_any(),
645                OrderSide::Sell,
646                Quantity::from("100"),
647                Price::from("1500.0"),
648                None,
649            )
650            .unwrap();
651        assert_eq!(balance_locked, Money::from("100 USD"));
652    }
653
654    #[rstest]
655    fn test_calculate_pnls_for_single_currency_cash_account(
656        cash_account_million_usd: CashAccount,
657        audusd_sim: CurrencyPair,
658    ) {
659        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
660        let order = OrderTestBuilder::new(OrderType::Market)
661            .instrument_id(audusd_sim.id())
662            .side(OrderSide::Buy)
663            .quantity(Quantity::from("1000000"))
664            .build();
665        let fill = TestOrderEventStubs::filled(
666            &order,
667            &audusd_sim,
668            None,
669            Some(PositionId::new("P-123456")),
670            Some(Price::from("0.8")),
671            None,
672            None,
673            None,
674            None,
675            Some(AccountId::from("SIM-001")),
676        );
677        let position = Position::new(&audusd_sim, fill.clone().into());
678        let fill_owned: crate::events::OrderFilled = fill.into();
679        let pnls = cash_account_million_usd
680            .calculate_pnls(&audusd_sim, &fill_owned, Some(position))
681            .unwrap();
682        assert_eq!(pnls, vec![Money::from("-800000 USD")]);
683    }
684
685    #[rstest]
686    fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
687        cash_account_multi: CashAccount,
688        currency_pair_btcusdt: CurrencyPair,
689    ) {
690        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt.clone());
691        let order1 = OrderTestBuilder::new(OrderType::Market)
692            .instrument_id(currency_pair_btcusdt.id)
693            .side(OrderSide::Sell)
694            .quantity(Quantity::from("0.5"))
695            .build();
696        let fill1 = TestOrderEventStubs::filled(
697            &order1,
698            &btcusdt,
699            None,
700            Some(PositionId::new("P-123456")),
701            Some(Price::from("45500.00")),
702            None,
703            None,
704            None,
705            None,
706            Some(AccountId::from("SIM-001")),
707        );
708        let position = Position::new(&btcusdt, fill1.clone().into());
709        let fill1_owned: crate::events::OrderFilled = fill1.into();
710        let result1 = cash_account_multi
711            .calculate_pnls(&btcusdt, &fill1_owned, Some(position.clone()))
712            .unwrap();
713        let order2 = OrderTestBuilder::new(OrderType::Market)
714            .instrument_id(currency_pair_btcusdt.id)
715            .side(OrderSide::Buy)
716            .quantity(Quantity::from("0.5"))
717            .build();
718        let fill2 = TestOrderEventStubs::filled(
719            &order2,
720            &btcusdt,
721            None,
722            Some(PositionId::new("P-123456")),
723            Some(Price::from("45500.00")),
724            None,
725            None,
726            None,
727            None,
728            Some(AccountId::from("SIM-001")),
729        );
730        let fill2_owned: crate::events::OrderFilled = fill2.into();
731        let result2 = cash_account_multi
732            .calculate_pnls(
733                &currency_pair_btcusdt.into_any(),
734                &fill2_owned,
735                Some(position),
736            )
737            .unwrap();
738        // use hash set to ignore order of results
739        let result1_set: AHashSet<Money> = result1.into_iter().collect();
740        let result1_expected: AHashSet<Money> =
741            vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
742                .into_iter()
743                .collect();
744        let result2_set: AHashSet<Money> = result2.into_iter().collect();
745        let result2_expected: AHashSet<Money> =
746            vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
747                .into_iter()
748                .collect();
749        assert_eq!(result1_set, result1_expected);
750        assert_eq!(result2_set, result2_expected);
751    }
752
753    #[rstest]
754    #[case(false, Money::from("-0.00218331 BTC"))]
755    #[case(true, Money::from("-25.0 USD"))]
756    fn test_calculate_commission_for_inverse_maker_crypto(
757        #[case] use_quote_for_inverse: bool,
758        #[case] expected: Money,
759        cash_account_million_usd: CashAccount,
760        xbtusd_bitmex: CryptoPerpetual,
761    ) {
762        let result = cash_account_million_usd
763            .calculate_commission(
764                &xbtusd_bitmex.into_any(),
765                Quantity::from("100000"),
766                Price::from("11450.50"),
767                LiquiditySide::Maker,
768                Some(use_quote_for_inverse),
769            )
770            .unwrap();
771        assert_eq!(result, expected);
772    }
773
774    #[rstest]
775    fn test_calculate_commission_for_taker_fx(
776        cash_account_million_usd: CashAccount,
777        audusd_sim: CurrencyPair,
778    ) {
779        let result = cash_account_million_usd
780            .calculate_commission(
781                &audusd_sim.into_any(),
782                Quantity::from("1500000"),
783                Price::from("0.8005"),
784                LiquiditySide::Taker,
785                None,
786            )
787            .unwrap();
788        assert_eq!(result, Money::from("24.02 USD"));
789    }
790
791    #[rstest]
792    fn test_calculate_commission_crypto_taker(
793        cash_account_million_usd: CashAccount,
794        xbtusd_bitmex: CryptoPerpetual,
795    ) {
796        let result = cash_account_million_usd
797            .calculate_commission(
798                &xbtusd_bitmex.into_any(),
799                Quantity::from("100000"),
800                Price::from("11450.50"),
801                LiquiditySide::Taker,
802                None,
803            )
804            .unwrap();
805        assert_eq!(result, Money::from("0.00654993 BTC"));
806    }
807
808    #[rstest]
809    fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
810        let instrument = usdjpy_idealpro();
811        let result = cash_account_million_usd
812            .calculate_commission(
813                &instrument.into_any(),
814                Quantity::from("2200000"),
815                Price::from("120.310"),
816                LiquiditySide::Taker,
817                None,
818            )
819            .unwrap();
820        assert_eq!(result, Money::from("5294 JPY"));
821    }
822
823    #[rstest]
824    fn test_update_balance_locked_per_instrument_currency(
825        mut cash_account_multi: CashAccount,
826        currency_pair_btcusdt: CurrencyPair,
827    ) {
828        assert!(cash_account_multi.balances_locked.is_empty());
829
830        let instrument_id = currency_pair_btcusdt.id;
831
832        let usdt_lock = Money::from("1000 USDT");
833        cash_account_multi.update_balance_locked(instrument_id, usdt_lock);
834
835        let btc_lock = Money::from("0.5 BTC");
836        cash_account_multi.update_balance_locked(instrument_id, btc_lock);
837        assert_eq!(cash_account_multi.balances_locked.len(), 2);
838        assert_eq!(
839            cash_account_multi
840                .balances_locked
841                .get(&(instrument_id, Currency::USDT())),
842            Some(&usdt_lock)
843        );
844        assert_eq!(
845            cash_account_multi
846                .balances_locked
847                .get(&(instrument_id, Currency::BTC())),
848            Some(&btc_lock)
849        );
850    }
851
852    #[rstest]
853    fn test_clear_balance_locked_removes_all_currencies_for_instrument(
854        mut cash_account_multi: CashAccount,
855        currency_pair_btcusdt: CurrencyPair,
856    ) {
857        let instrument_id = currency_pair_btcusdt.id;
858
859        cash_account_multi.update_balance_locked(instrument_id, Money::from("1000 USDT"));
860        cash_account_multi.update_balance_locked(instrument_id, Money::from("0.5 BTC"));
861        assert_eq!(cash_account_multi.balances_locked.len(), 2);
862
863        cash_account_multi.clear_balance_locked(instrument_id);
864
865        assert!(cash_account_multi.balances_locked.is_empty());
866    }
867
868    #[rstest]
869    fn test_clear_balance_locked_only_removes_target_instrument(
870        mut cash_account_multi: CashAccount,
871        currency_pair_btcusdt: CurrencyPair,
872    ) {
873        let btcusdt_id = currency_pair_btcusdt.id;
874        let ethusdt_id = InstrumentId::from("ETHUSDT.BINANCE");
875
876        cash_account_multi.update_balance_locked(btcusdt_id, Money::from("1000 USDT"));
877        cash_account_multi.update_balance_locked(ethusdt_id, Money::from("500 USDT"));
878        assert_eq!(cash_account_multi.balances_locked.len(), 2);
879
880        cash_account_multi.clear_balance_locked(btcusdt_id);
881        assert_eq!(cash_account_multi.balances_locked.len(), 1);
882        assert_eq!(
883            cash_account_multi
884                .balances_locked
885                .get(&(ethusdt_id, Currency::USDT())),
886            Some(&Money::from("500 USDT"))
887        );
888    }
889
890    #[rstest]
891    fn test_recalculate_balance_clamps_when_locked_exceeds_total(
892        mut cash_account_multi: CashAccount,
893        currency_pair_btcusdt: CurrencyPair,
894    ) {
895        let initial_balance = *cash_account_multi.balance(Some(Currency::BTC())).unwrap();
896        assert_eq!(initial_balance.total, Money::from("10 BTC"));
897
898        // Lock more than total to simulate latency/state mismatch
899        let instrument_id = currency_pair_btcusdt.id;
900        cash_account_multi.update_balance_locked(instrument_id, Money::from("15 BTC"));
901
902        let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
903        assert_eq!(balance.total, Money::from("10 BTC"));
904        assert_eq!(balance.locked, Money::from("10 BTC"));
905        assert_eq!(balance.free, Money::from("0 BTC"));
906    }
907
908    #[rstest]
909    fn test_recalculate_balance_sums_multiple_instrument_locks(
910        mut cash_account_multi: CashAccount,
911    ) {
912        let btcusdt_id = InstrumentId::from("BTCUSDT.BINANCE");
913        let btceth_id = InstrumentId::from("BTCETH.BINANCE");
914
915        cash_account_multi.update_balance_locked(btcusdt_id, Money::from("3 BTC"));
916        cash_account_multi.update_balance_locked(btceth_id, Money::from("2 BTC"));
917
918        let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
919        assert_eq!(balance.total, Money::from("10 BTC"));
920        assert_eq!(balance.locked, Money::from("5 BTC"));
921        assert_eq!(balance.free, Money::from("5 BTC"));
922    }
923
924    #[rstest]
925    fn test_recalculate_balance_no_clamp_when_total_negative_borrowing() {
926        // Create account with negative balance (simulating borrowing)
927        let negative_balance_event = AccountState::new(
928            AccountId::from("SIM-001"),
929            AccountType::Cash,
930            vec![AccountBalance::new(
931                Money::from("-1000 USD"), // Negative total (borrowed)
932                Money::from("0 USD"),
933                Money::from("-1000 USD"),
934            )],
935            vec![],
936            true,
937            uuid4(),
938            0.into(),
939            0.into(),
940            Some(Currency::USD()),
941        );
942
943        let mut account = CashAccount::new(negative_balance_event, false, true);
944        let instrument_id = InstrumentId::from("EURUSD.SIM");
945
946        account.update_balance_locked(instrument_id, Money::from("500 USD"));
947
948        // Locked not clamped to negative total, free = total - locked
949        let balance = account.balance(Some(Currency::USD())).unwrap();
950        assert_eq!(balance.total, Money::from("-1000 USD"));
951        assert_eq!(balance.locked, Money::from("500 USD"));
952        assert_eq!(balance.free, Money::from("-1500 USD"));
953    }
954
955    #[rstest]
956    fn test_apply_returns_error_when_negative_balance_and_borrowing_disabled() {
957        let initial_event = AccountState::new(
958            AccountId::from("SIM-001"),
959            AccountType::Cash,
960            vec![AccountBalance::new(
961                Money::from("1000 USD"),
962                Money::from("0 USD"),
963                Money::from("1000 USD"),
964            )],
965            vec![],
966            true,
967            uuid4(),
968            0.into(),
969            0.into(),
970            Some(Currency::USD()),
971        );
972
973        let mut account = CashAccount::new(initial_event, false, false);
974
975        let negative_balance_event = AccountState::new(
976            AccountId::from("SIM-001"),
977            AccountType::Cash,
978            vec![AccountBalance::new(
979                Money::from("-500 USD"),
980                Money::from("0 USD"),
981                Money::from("-500 USD"),
982            )],
983            vec![],
984            true,
985            uuid4(),
986            1.into(),
987            1.into(),
988            Some(Currency::USD()),
989        );
990
991        let result = account.apply(negative_balance_event);
992
993        assert!(result.is_err());
994        let err_msg = result.unwrap_err().to_string();
995        assert!(err_msg.contains("negative"));
996        assert!(err_msg.contains("borrowing not allowed"));
997    }
998
999    #[rstest]
1000    fn test_apply_succeeds_when_negative_balance_and_borrowing_enabled() {
1001        let initial_event = AccountState::new(
1002            AccountId::from("SIM-001"),
1003            AccountType::Cash,
1004            vec![AccountBalance::new(
1005                Money::from("1000 USD"),
1006                Money::from("0 USD"),
1007                Money::from("1000 USD"),
1008            )],
1009            vec![],
1010            true,
1011            uuid4(),
1012            0.into(),
1013            0.into(),
1014            Some(Currency::USD()),
1015        );
1016
1017        let mut account = CashAccount::new(initial_event, false, true);
1018
1019        let negative_balance_event = AccountState::new(
1020            AccountId::from("SIM-001"),
1021            AccountType::Cash,
1022            vec![AccountBalance::new(
1023                Money::from("-500 USD"),
1024                Money::from("0 USD"),
1025                Money::from("-500 USD"),
1026            )],
1027            vec![],
1028            true,
1029            uuid4(),
1030            1.into(),
1031            1.into(),
1032            Some(Currency::USD()),
1033        );
1034
1035        let result = account.apply(negative_balance_event);
1036
1037        assert!(result.is_ok());
1038        assert_eq!(
1039            account.balance_total(Some(Currency::USD())),
1040            Some(Money::from("-500 USD"))
1041        );
1042    }
1043
1044    #[rstest]
1045    fn test_apply_clears_per_instrument_locks() {
1046        let initial_event = AccountState::new(
1047            AccountId::from("SIM-001"),
1048            AccountType::Cash,
1049            vec![AccountBalance::new(
1050                Money::from("10000 USD"),
1051                Money::from("0 USD"),
1052                Money::from("10000 USD"),
1053            )],
1054            vec![],
1055            true,
1056            uuid4(),
1057            0.into(),
1058            0.into(),
1059            Some(Currency::USD()),
1060        );
1061
1062        let mut account = CashAccount::new(initial_event, false, false);
1063        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1064
1065        // Set per-instrument lock
1066        account.update_balance_locked(instrument_id, Money::from("5000 USD"));
1067        assert_eq!(account.balances_locked.len(), 1);
1068
1069        // Apply new state - should clear per-instrument locks
1070        let new_event = AccountState::new(
1071            AccountId::from("SIM-001"),
1072            AccountType::Cash,
1073            vec![AccountBalance::new(
1074                Money::from("8000 USD"),
1075                Money::from("0 USD"),
1076                Money::from("8000 USD"),
1077            )],
1078            vec![],
1079            true,
1080            uuid4(),
1081            1.into(),
1082            1.into(),
1083            Some(Currency::USD()),
1084        );
1085
1086        account.apply(new_event).unwrap();
1087
1088        assert!(account.balances_locked.is_empty());
1089        assert_eq!(
1090            account.balance_total(Some(Currency::USD())),
1091            Some(Money::from("8000 USD"))
1092        );
1093    }
1094
1095    #[rstest]
1096    fn test_apply_empty_balances_preserves_per_instrument_locks() {
1097        let initial_event = AccountState::new(
1098            AccountId::from("SIM-001"),
1099            AccountType::Cash,
1100            vec![AccountBalance::new(
1101                Money::from("10000 USD"),
1102                Money::from("0 USD"),
1103                Money::from("10000 USD"),
1104            )],
1105            vec![],
1106            true,
1107            uuid4(),
1108            0.into(),
1109            0.into(),
1110            Some(Currency::USD()),
1111        );
1112
1113        let mut account = CashAccount::new(initial_event, false, false);
1114        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1115        account.update_balance_locked(instrument_id, Money::from("5000 USD"));
1116        assert_eq!(account.balances_locked.len(), 1);
1117
1118        let empty_event = AccountState::new(
1119            AccountId::from("SIM-001"),
1120            AccountType::Cash,
1121            vec![],
1122            vec![],
1123            true,
1124            uuid4(),
1125            1.into(),
1126            1.into(),
1127            Some(Currency::USD()),
1128        );
1129
1130        account.apply(empty_event).unwrap();
1131
1132        assert_eq!(account.balances_locked.len(), 1);
1133        assert_eq!(
1134            account.balance_total(Some(Currency::USD())),
1135            Some(Money::from("10000 USD"))
1136        );
1137        assert_eq!(account.event_count(), 2);
1138    }
1139}