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