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