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