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