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::*, order::spec::OrderFilledSpec},
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    #[case(OrderSide::Buy, "0.01", "0.001234 USD")]
557    #[case(OrderSide::Sell, "0.0001", "0.0001 AUD")]
558    fn test_balance_locked_preserves_native_precision(
559        mut cash_account_million_usd: CashAccount,
560        audusd_sim: CurrencyPair,
561        #[case] side: OrderSide,
562        #[case] qty: &str,
563        #[case] expected: &str,
564    ) {
565        let instrument = audusd_sim.into_any();
566        let locked = cash_account_million_usd
567            .calculate_balance_locked(
568                &instrument,
569                side,
570                Quantity::from(qty),
571                Price::from("0.1234"),
572                None,
573            )
574            .unwrap();
575        cash_account_million_usd
576            .update_balance_locked(instrument.id(), locked)
577            .unwrap();
578        let expected = Money::from_str_exact(expected).unwrap();
579
580        assert_eq!(locked, expected);
581        assert_eq!(
582            cash_account_million_usd.balances_locked[&(instrument.id(), expected.currency)],
583            expected,
584        );
585
586        if side == OrderSide::Buy {
587            assert_eq!(
588                cash_account_million_usd
589                    .balance(Some(Currency::USD()))
590                    .copied(),
591                Some(AccountBalance::new(
592                    Money::from("1000000 USD"),
593                    expected,
594                    Money::from_str_exact("999999.998766 USD").unwrap(),
595                )),
596            );
597        }
598    }
599
600    #[rstest]
601    #[case::profit("0 USD", "1000000.000001 USD")]
602    #[case::fee("0.000001 USD", "1000000 USD")]
603    fn test_fill_cash_flows_preserve_micro_profit_and_fee(
604        mut cash_account_million_usd: CashAccount,
605        audusd_sim: CurrencyPair,
606        #[case] fee: &str,
607        #[case] expected: &str,
608    ) {
609        let instrument = audusd_sim.into_any();
610        let fee = Money::from_str_exact(fee).unwrap();
611        let mut cash_flows = Vec::new();
612
613        for (side, price, commission) in [
614            (OrderSide::Buy, "0.1234", fee),
615            (OrderSide::Sell, "0.1235", Money::zero(Currency::USD())),
616        ] {
617            let fill = OrderFilledSpec::builder()
618                .instrument_id(instrument.id())
619                .order_side(side)
620                .last_qty(Quantity::from("0.01"))
621                .last_px(Price::from(price))
622                .commission(commission)
623                .build();
624            let pnls = cash_account_million_usd
625                .calculate_pnls(&instrument, &fill, None)
626                .unwrap();
627            let current = cash_account_million_usd.balance_total(None).unwrap();
628            let total = current
629                .checked_add(pnls[0])
630                .unwrap()
631                .checked_sub(commission)
632                .unwrap();
633            let balance = AccountBalance::new(total, Money::zero(Currency::USD()), total);
634            cash_account_million_usd
635                .update_balances(&[balance])
636                .unwrap();
637            cash_flows.extend(pnls);
638        }
639
640        let expected = Money::from_str_exact(expected).unwrap();
641        assert_eq!(
642            cash_flows,
643            vec![
644                Money::from_str_exact("-0.001234 USD").unwrap(),
645                Money::from_str_exact("0.001235 USD").unwrap(),
646            ],
647        );
648        assert_eq!(
649            cash_account_million_usd.balance(None).copied(),
650            Some(AccountBalance::new(
651                expected,
652                Money::zero(Currency::USD()),
653                expected
654            )),
655        );
656    }
657
658    #[rstest]
659    fn test_calculate_balance_locked_buy_returns_error_for_unrepresentable_notional(
660        cash_account_million_usd: CashAccount,
661        audusd_sim: CurrencyPair,
662    ) {
663        let result = cash_account_million_usd.calculate_balance_locked(
664            &audusd_sim.into_any(),
665            OrderSide::Buy,
666            Quantity::from("100000000"),
667            Price::from("100000000"),
668            None,
669        );
670
671        assert!(result.is_err());
672    }
673
674    #[rstest]
675    fn test_calculate_balance_locked_buy_quanto_uses_settlement_currency(
676        cash_account_million_usd: CashAccount,
677        ethbtc_quanto: CryptoFuture,
678    ) {
679        let balance_locked = cash_account_million_usd
680            .calculate_balance_locked(
681                &ethbtc_quanto.into_any(),
682                OrderSide::Buy,
683                Quantity::from("5"),
684                Price::from("0.036"),
685                None,
686            )
687            .unwrap();
688        assert_eq!(balance_locked, Money::from("0.18 USDT"));
689    }
690
691    #[rstest]
692    #[case(false, Money::from("0.002 BTC"))]
693    #[case(true, Money::from("100 USD"))]
694    fn test_calculate_balance_locked_buy_inverse_respects_quote_flag(
695        #[case] use_quote_for_inverse: bool,
696        #[case] expected: Money,
697        cash_account_million_usd: CashAccount,
698        btcusd_inverse_perp: CryptoPerpetual,
699    ) {
700        let balance_locked = cash_account_million_usd
701            .calculate_balance_locked(
702                &btcusd_inverse_perp.into_any(),
703                OrderSide::Buy,
704                Quantity::from("100"),
705                Price::from("50000"),
706                Some(use_quote_for_inverse),
707            )
708            .unwrap();
709        assert_eq!(balance_locked, expected);
710    }
711
712    #[rstest]
713    fn test_calculate_balance_locked_sell(
714        cash_account_million_usd: CashAccount,
715        audusd_sim: CurrencyPair,
716    ) {
717        let balance_locked = cash_account_million_usd
718            .calculate_balance_locked(
719                &audusd_sim.into_any(),
720                OrderSide::Sell,
721                Quantity::from("1000000"),
722                Price::from("0.8"),
723                None,
724            )
725            .unwrap();
726        assert_eq!(balance_locked, Money::from("1000000 AUD"));
727    }
728
729    #[rstest]
730    fn test_calculate_balance_locked_sell_no_base_currency(
731        cash_account_million_usd: CashAccount,
732        equity_aapl: Equity,
733    ) {
734        let balance_locked = cash_account_million_usd
735            .calculate_balance_locked(
736                &equity_aapl.into_any(),
737                OrderSide::Sell,
738                Quantity::from("100"),
739                Price::from("1500.0"),
740                None,
741            )
742            .unwrap();
743        assert_eq!(balance_locked, Money::zero(Currency::USD()));
744    }
745
746    #[rstest]
747    fn test_calculate_balance_locked_sell_binary_option_reserves_nothing(
748        cash_account_million_usd: CashAccount,
749        binary_option: BinaryOption,
750    ) {
751        // BinaryOption::base_currency() is None: there is no modeled asset to reserve, so a
752        // SELL must not reserve quote collateral.
753        let balance_locked = cash_account_million_usd
754            .calculate_balance_locked(
755                &InstrumentAny::BinaryOption(binary_option),
756                OrderSide::Sell,
757                Quantity::from("5.00"),
758                Price::from("0.500"),
759                None,
760            )
761            .unwrap();
762        assert_eq!(balance_locked, Money::zero(Currency::USDC()));
763    }
764
765    #[rstest]
766    fn test_calculate_balance_locked_buy_no_base_currency_still_reserves_notional(
767        cash_account_million_usd: CashAccount,
768        binary_option: BinaryOption,
769    ) {
770        let balance_locked = cash_account_million_usd
771            .calculate_balance_locked(
772                &binary_option.into_any(),
773                OrderSide::Buy,
774                Quantity::from("5"),
775                Price::from("0.500"),
776                None,
777            )
778            .unwrap();
779        assert_eq!(balance_locked, Money::new(2.5, Currency::USDC()));
780    }
781
782    #[rstest]
783    fn test_calculate_pnls_for_single_currency_cash_account(
784        cash_account_million_usd: CashAccount,
785        audusd_sim: CurrencyPair,
786    ) {
787        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
788        let order = OrderTestBuilder::new(OrderType::Market)
789            .instrument_id(audusd_sim.id())
790            .side(OrderSide::Buy)
791            .quantity(Quantity::from("1000000"))
792            .build();
793        let fill = TestOrderEventStubs::filled(
794            &order,
795            &audusd_sim,
796            None,
797            Some(PositionId::new("P-123456")),
798            Some(Price::from("0.8")),
799            None,
800            None,
801            None,
802            None,
803            Some(AccountId::from("SIM-001")),
804        );
805        let position = Position::new(&audusd_sim, fill.clone().into());
806        let fill_owned: crate::events::OrderFilled = fill.into();
807        let pnls = cash_account_million_usd
808            .calculate_pnls(&audusd_sim, &fill_owned, Some(position))
809            .unwrap();
810        assert_eq!(pnls, vec![Money::from("-800000 USD")]);
811    }
812
813    #[cfg(feature = "defi")]
814    #[rstest]
815    fn test_fill_cash_flows_reject_unrepresentable_foreign_commission(
816        cash_account: CashAccount,
817        audusd_sim: CurrencyPair,
818    ) {
819        let instrument = audusd_sim.into_any();
820        let fill = OrderFilledSpec::builder()
821            .instrument_id(instrument.id())
822            .last_qty(Quantity::from("0.01"))
823            .last_px(Price::from("0.1234"))
824            .commission(Money::from_raw(
825                100_000_000_000_000_000_000_000_000_001,
826                Currency::EUR(),
827            ))
828            .build();
829
830        let error = cash_account
831            .calculate_pnls(&instrument, &fill, None)
832            .unwrap_err();
833
834        assert_eq!(
835            error.to_string(),
836            "position input exceeds exact Decimal representation"
837        );
838    }
839
840    #[rstest]
841    fn test_fill_cash_flows_preserve_sub_cent_base_and_quote(
842        cash_account_multi: CashAccount,
843        audusd_sim: CurrencyPair,
844    ) {
845        let instrument = audusd_sim.into_any();
846        let fill = OrderFilledSpec::builder()
847            .instrument_id(instrument.id())
848            .order_side(OrderSide::Buy)
849            .last_qty(Quantity::from("0.0001"))
850            .last_px(Price::from("0.1234"))
851            .commission(Money::zero(Currency::USD()))
852            .build();
853
854        let pnls = cash_account_multi
855            .calculate_pnls(&instrument, &fill, None)
856            .unwrap();
857
858        assert_eq!(
859            pnls,
860            vec![
861                Money::from_str_exact("0.0001 AUD").unwrap(),
862                Money::from_str_exact("-0.00001234 USD").unwrap(),
863            ],
864        );
865    }
866
867    #[rstest]
868    fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
869        cash_account_multi: CashAccount,
870        currency_pair_btcusdt: CurrencyPair,
871    ) {
872        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt.clone());
873        let order1 = OrderTestBuilder::new(OrderType::Market)
874            .instrument_id(currency_pair_btcusdt.id)
875            .side(OrderSide::Sell)
876            .quantity(Quantity::from("0.5"))
877            .build();
878        let fill1 = TestOrderEventStubs::filled(
879            &order1,
880            &btcusdt,
881            None,
882            Some(PositionId::new("P-123456")),
883            Some(Price::from("45500.00")),
884            None,
885            None,
886            None,
887            None,
888            Some(AccountId::from("SIM-001")),
889        );
890        let position = Position::new(&btcusdt, fill1.clone().into());
891        let fill1_owned: crate::events::OrderFilled = fill1.into();
892        let result1 = cash_account_multi
893            .calculate_pnls(&btcusdt, &fill1_owned, Some(position.clone()))
894            .unwrap();
895        let order2 = OrderTestBuilder::new(OrderType::Market)
896            .instrument_id(currency_pair_btcusdt.id)
897            .side(OrderSide::Buy)
898            .quantity(Quantity::from("0.5"))
899            .build();
900        let fill2 = TestOrderEventStubs::filled(
901            &order2,
902            &btcusdt,
903            None,
904            Some(PositionId::new("P-123456")),
905            Some(Price::from("45500.00")),
906            None,
907            None,
908            None,
909            None,
910            Some(AccountId::from("SIM-001")),
911        );
912        let fill2_owned: crate::events::OrderFilled = fill2.into();
913        let result2 = cash_account_multi
914            .calculate_pnls(
915                &currency_pair_btcusdt.into_any(),
916                &fill2_owned,
917                Some(position),
918            )
919            .unwrap();
920        // use hash set to ignore order of results
921        let result1_set: AHashSet<Money> = result1.into_iter().collect();
922        let result1_expected: AHashSet<Money> =
923            vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
924                .into_iter()
925                .collect();
926        let result2_set: AHashSet<Money> = result2.into_iter().collect();
927        let result2_expected: AHashSet<Money> =
928            vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
929                .into_iter()
930                .collect();
931        assert_eq!(result1_set, result1_expected);
932        assert_eq!(result2_set, result2_expected);
933    }
934
935    #[rstest]
936    #[case(false, Money::from("-0.00218331 BTC"))]
937    #[case(true, Money::from("-25.0 USD"))]
938    fn test_calculate_commission_for_inverse_maker_crypto(
939        #[case] use_quote_for_inverse: bool,
940        #[case] expected: Money,
941        cash_account_million_usd: CashAccount,
942        btcusd_bybit: CryptoPerpetual,
943    ) {
944        let fee_rates = MakerTakerFeeRates::new(dec!(-0.00025), dec!(0.00075));
945        let result = cash_account_million_usd
946            .calculate_commission(
947                &btcusd_bybit.into_any(),
948                Quantity::from("100000"),
949                Price::from("11450.50"),
950                LiquiditySide::Maker,
951                fee_rates,
952                Some(use_quote_for_inverse),
953            )
954            .unwrap();
955        assert_eq!(result, expected);
956    }
957
958    #[rstest]
959    fn test_calculate_commission_for_taker_fx(
960        cash_account_million_usd: CashAccount,
961        audusd_sim: CurrencyPair,
962    ) {
963        let fee_rates = MakerTakerFeeRates::new(dec!(0.00002), dec!(0.00002));
964        let result = cash_account_million_usd
965            .calculate_commission(
966                &audusd_sim.into_any(),
967                Quantity::from("1500000"),
968                Price::from("0.8005"),
969                LiquiditySide::Taker,
970                fee_rates,
971                None,
972            )
973            .unwrap();
974        assert_eq!(result, Money::from("24.02 USD"));
975    }
976
977    #[rstest]
978    fn test_calculate_commission_crypto_taker(
979        cash_account_million_usd: CashAccount,
980        btcusd_bybit: CryptoPerpetual,
981    ) {
982        let fee_rates = MakerTakerFeeRates::new(dec!(-0.00025), dec!(0.00075));
983        let result = cash_account_million_usd
984            .calculate_commission(
985                &btcusd_bybit.into_any(),
986                Quantity::from("100000"),
987                Price::from("11450.50"),
988                LiquiditySide::Taker,
989                fee_rates,
990                None,
991            )
992            .unwrap();
993        assert_eq!(result, Money::from("0.00654993 BTC"));
994    }
995
996    #[rstest]
997    fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
998        let instrument = usdjpy_idealpro();
999        let fee_rates = MakerTakerFeeRates::new(dec!(0.00002), dec!(0.00002));
1000        let result = cash_account_million_usd
1001            .calculate_commission(
1002                &instrument.into_any(),
1003                Quantity::from("2200000"),
1004                Price::from("120.310"),
1005                LiquiditySide::Taker,
1006                fee_rates,
1007                None,
1008            )
1009            .unwrap();
1010        assert_eq!(result, Money::from("5294 JPY"));
1011    }
1012
1013    #[rstest]
1014    fn test_update_balance_locked_per_instrument_currency(
1015        mut cash_account_multi: CashAccount,
1016        currency_pair_btcusdt: CurrencyPair,
1017    ) {
1018        assert!(cash_account_multi.balances_locked.is_empty());
1019
1020        let instrument_id = currency_pair_btcusdt.id;
1021
1022        let usdt_lock = Money::from("1000 USDT");
1023        cash_account_multi
1024            .update_balance_locked(instrument_id, usdt_lock)
1025            .unwrap();
1026
1027        let btc_lock = Money::from("0.5 BTC");
1028        cash_account_multi
1029            .update_balance_locked(instrument_id, btc_lock)
1030            .unwrap();
1031        assert_eq!(cash_account_multi.balances_locked.len(), 2);
1032        assert_eq!(
1033            cash_account_multi
1034                .balances_locked
1035                .get(&(instrument_id, Currency::USDT())),
1036            Some(&usdt_lock)
1037        );
1038        assert_eq!(
1039            cash_account_multi
1040                .balances_locked
1041                .get(&(instrument_id, Currency::BTC())),
1042            Some(&btc_lock)
1043        );
1044    }
1045
1046    #[rstest]
1047    fn test_clear_balance_locked_removes_all_currencies_for_instrument(
1048        mut cash_account_multi: CashAccount,
1049        currency_pair_btcusdt: CurrencyPair,
1050    ) {
1051        let instrument_id = currency_pair_btcusdt.id;
1052
1053        cash_account_multi
1054            .update_balance_locked(instrument_id, Money::from("1000 USDT"))
1055            .unwrap();
1056        cash_account_multi
1057            .update_balance_locked(instrument_id, Money::from("0.5 BTC"))
1058            .unwrap();
1059        assert_eq!(cash_account_multi.balances_locked.len(), 2);
1060
1061        cash_account_multi.clear_balance_locked(instrument_id);
1062
1063        assert!(cash_account_multi.balances_locked.is_empty());
1064    }
1065
1066    #[rstest]
1067    fn test_clear_balance_locked_only_removes_target_instrument(
1068        mut cash_account_multi: CashAccount,
1069        currency_pair_btcusdt: CurrencyPair,
1070    ) {
1071        let btcusdt_id = currency_pair_btcusdt.id;
1072        let ethusdt_id = InstrumentId::from("ETHUSDT.BINANCE");
1073
1074        cash_account_multi
1075            .update_balance_locked(btcusdt_id, Money::from("1000 USDT"))
1076            .unwrap();
1077        cash_account_multi
1078            .update_balance_locked(ethusdt_id, Money::from("500 USDT"))
1079            .unwrap();
1080        assert_eq!(cash_account_multi.balances_locked.len(), 2);
1081
1082        cash_account_multi.clear_balance_locked(btcusdt_id);
1083        assert_eq!(cash_account_multi.balances_locked.len(), 1);
1084        assert_eq!(
1085            cash_account_multi
1086                .balances_locked
1087                .get(&(ethusdt_id, Currency::USDT())),
1088            Some(&Money::from("500 USDT"))
1089        );
1090    }
1091
1092    #[rstest]
1093    fn test_recalculate_balance_clamps_when_locked_exceeds_total(
1094        mut cash_account_multi: CashAccount,
1095        currency_pair_btcusdt: CurrencyPair,
1096    ) {
1097        let initial_balance = *cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1098        assert_eq!(initial_balance.total, Money::from("10 BTC"));
1099
1100        // Lock more than total to simulate latency/state mismatch
1101        let instrument_id = currency_pair_btcusdt.id;
1102        cash_account_multi
1103            .update_balance_locked(instrument_id, Money::from("15 BTC"))
1104            .unwrap();
1105
1106        let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1107        assert_eq!(balance.total, Money::from("10 BTC"));
1108        assert_eq!(balance.locked, Money::from("10 BTC"));
1109        assert_eq!(balance.free, Money::from("0 BTC"));
1110    }
1111
1112    #[rstest]
1113    fn test_recalculate_balance_sums_multiple_instrument_locks(
1114        mut cash_account_multi: CashAccount,
1115    ) {
1116        let btcusdt_id = InstrumentId::from("BTCUSDT.BINANCE");
1117        let btceth_id = InstrumentId::from("BTCETH.BINANCE");
1118
1119        cash_account_multi
1120            .update_balance_locked(btcusdt_id, Money::from("3 BTC"))
1121            .unwrap();
1122        cash_account_multi
1123            .update_balance_locked(btceth_id, Money::from("2 BTC"))
1124            .unwrap();
1125
1126        let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1127        assert_eq!(balance.total, Money::from("10 BTC"));
1128        assert_eq!(balance.locked, Money::from("5 BTC"));
1129        assert_eq!(balance.free, Money::from("5 BTC"));
1130    }
1131
1132    #[rstest]
1133    fn test_update_balance_locked_precision_mismatch_preserves_state(
1134        mut cash_account_multi: CashAccount,
1135    ) {
1136        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1137        let btc = Currency::BTC();
1138        cash_account_multi
1139            .update_balance_locked(instrument_id, Money::from("3 BTC"))
1140            .unwrap();
1141        let balance_before = *cash_account_multi.balance(Some(btc)).unwrap();
1142        let locks_before = cash_account_multi.balances_locked.clone();
1143        let mismatched_btc =
1144            Currency::new("BTC", btc.precision - 1, 0, "Bitcoin", CurrencyType::Crypto);
1145        let locked = Money::from_decimal(Decimal::from(2), mismatched_btc).unwrap();
1146
1147        let error = cash_account_multi
1148            .update_balance_locked(instrument_id, locked)
1149            .unwrap_err();
1150
1151        assert_eq!(
1152            error.to_string(),
1153            "Cannot update BTC reservation: precision 7 differed from balance precision 8"
1154        );
1155        assert_eq!(cash_account_multi.balance(Some(btc)), Some(&balance_before));
1156        assert_eq!(cash_account_multi.balances_locked, locks_before);
1157    }
1158
1159    #[rstest]
1160    fn test_recalculate_balance_no_clamp_when_total_negative_borrowing() {
1161        // Create account with negative balance (simulating borrowing)
1162        let negative_balance_event = AccountState::new(
1163            AccountId::from("SIM-001"),
1164            AccountType::Cash,
1165            vec![AccountBalance::new(
1166                Money::from("-1000 USD"), // Negative total (borrowed)
1167                Money::from("0 USD"),
1168                Money::from("-1000 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(negative_balance_event, false, true);
1179        let instrument_id = InstrumentId::from("EURUSD.SIM");
1180
1181        account
1182            .update_balance_locked(instrument_id, Money::from("500 USD"))
1183            .unwrap();
1184
1185        // Locked not clamped to negative total, free = total - locked
1186        let balance = account.balance(Some(Currency::USD())).unwrap();
1187        assert_eq!(balance.total, Money::from("-1000 USD"));
1188        assert_eq!(balance.locked, Money::from("500 USD"));
1189        assert_eq!(balance.free, Money::from("-1500 USD"));
1190    }
1191
1192    #[rstest]
1193    fn test_update_balances_rejects_negative_total_when_borrowing_disabled(
1194        mut cash_account: CashAccount,
1195    ) {
1196        let usd = Currency::USD();
1197        let balances_before = cash_account.balances();
1198
1199        let result = cash_account.update_balances(&[AccountBalance::new(
1200            Money::from("-500 USD"),
1201            Money::zero(usd),
1202            Money::from("-500 USD"),
1203        )]);
1204
1205        assert_eq!(
1206            result.unwrap_err().to_string(),
1207            "Cash account balance would become negative: -500.00 USD (borrowing not allowed for SIM-001)"
1208        );
1209        assert_eq!(cash_account.balances(), balances_before);
1210    }
1211
1212    #[rstest]
1213    fn test_update_balances_accepts_negative_total_when_borrowing_enabled(
1214        mut cash_account_borrowing: CashAccount,
1215    ) {
1216        let usd = Currency::USD();
1217        let balance = AccountBalance::new(
1218            Money::from("-500 USD"),
1219            Money::zero(usd),
1220            Money::from("-500 USD"),
1221        );
1222
1223        cash_account_borrowing.update_balances(&[balance]).unwrap();
1224
1225        assert_eq!(cash_account_borrowing.balance(Some(usd)), Some(&balance));
1226    }
1227
1228    #[rstest]
1229    fn test_apply_returns_error_when_negative_balance_and_borrowing_disabled() {
1230        let initial_event = AccountState::new(
1231            AccountId::from("SIM-001"),
1232            AccountType::Cash,
1233            vec![AccountBalance::new(
1234                Money::from("1000 USD"),
1235                Money::from("0 USD"),
1236                Money::from("1000 USD"),
1237            )],
1238            vec![],
1239            true,
1240            uuid4(),
1241            0.into(),
1242            0.into(),
1243            Some(Currency::USD()),
1244        );
1245
1246        let mut account = CashAccount::new(initial_event, false, false);
1247
1248        let negative_balance_event = AccountState::new(
1249            AccountId::from("SIM-001"),
1250            AccountType::Cash,
1251            vec![AccountBalance::new(
1252                Money::from("-500 USD"),
1253                Money::from("0 USD"),
1254                Money::from("-500 USD"),
1255            )],
1256            vec![],
1257            true,
1258            uuid4(),
1259            1.into(),
1260            1.into(),
1261            Some(Currency::USD()),
1262        );
1263
1264        let result = account.apply(negative_balance_event);
1265
1266        assert!(result.is_err());
1267        let err_msg = result.unwrap_err().to_string();
1268        assert!(err_msg.contains("negative"));
1269        assert!(err_msg.contains("borrowing not allowed"));
1270    }
1271
1272    #[rstest]
1273    fn test_apply_succeeds_when_negative_balance_and_borrowing_enabled() {
1274        let initial_event = AccountState::new(
1275            AccountId::from("SIM-001"),
1276            AccountType::Cash,
1277            vec![AccountBalance::new(
1278                Money::from("1000 USD"),
1279                Money::from("0 USD"),
1280                Money::from("1000 USD"),
1281            )],
1282            vec![],
1283            true,
1284            uuid4(),
1285            0.into(),
1286            0.into(),
1287            Some(Currency::USD()),
1288        );
1289
1290        let mut account = CashAccount::new(initial_event, false, true);
1291
1292        let negative_balance_event = AccountState::new(
1293            AccountId::from("SIM-001"),
1294            AccountType::Cash,
1295            vec![AccountBalance::new(
1296                Money::from("-500 USD"),
1297                Money::from("0 USD"),
1298                Money::from("-500 USD"),
1299            )],
1300            vec![],
1301            true,
1302            uuid4(),
1303            1.into(),
1304            1.into(),
1305            Some(Currency::USD()),
1306        );
1307
1308        let result = account.apply(negative_balance_event);
1309
1310        assert!(result.is_ok());
1311        assert_eq!(
1312            account.balance_total(Some(Currency::USD())),
1313            Some(Money::from("-500 USD"))
1314        );
1315    }
1316
1317    #[rstest]
1318    fn test_apply_clears_per_instrument_locks() {
1319        let initial_event = AccountState::new(
1320            AccountId::from("SIM-001"),
1321            AccountType::Cash,
1322            vec![AccountBalance::new(
1323                Money::from("10000 USD"),
1324                Money::from("0 USD"),
1325                Money::from("10000 USD"),
1326            )],
1327            vec![],
1328            true,
1329            uuid4(),
1330            0.into(),
1331            0.into(),
1332            Some(Currency::USD()),
1333        );
1334
1335        let mut account = CashAccount::new(initial_event, false, false);
1336        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1337
1338        // Set per-instrument lock
1339        account
1340            .update_balance_locked(instrument_id, Money::from("5000 USD"))
1341            .unwrap();
1342        assert_eq!(account.balances_locked.len(), 1);
1343
1344        // Apply new state - should clear per-instrument locks
1345        let new_event = AccountState::new(
1346            AccountId::from("SIM-001"),
1347            AccountType::Cash,
1348            vec![AccountBalance::new(
1349                Money::from("8000 USD"),
1350                Money::from("0 USD"),
1351                Money::from("8000 USD"),
1352            )],
1353            vec![],
1354            true,
1355            uuid4(),
1356            1.into(),
1357            1.into(),
1358            Some(Currency::USD()),
1359        );
1360
1361        account.apply(new_event).unwrap();
1362
1363        assert!(account.balances_locked.is_empty());
1364        assert_eq!(
1365            account.balance_total(Some(Currency::USD())),
1366            Some(Money::from("8000 USD"))
1367        );
1368    }
1369
1370    #[rstest]
1371    fn test_apply_empty_balances_preserves_per_instrument_locks() {
1372        let initial_event = AccountState::new(
1373            AccountId::from("SIM-001"),
1374            AccountType::Cash,
1375            vec![AccountBalance::new(
1376                Money::from("10000 USD"),
1377                Money::from("0 USD"),
1378                Money::from("10000 USD"),
1379            )],
1380            vec![],
1381            true,
1382            uuid4(),
1383            0.into(),
1384            0.into(),
1385            Some(Currency::USD()),
1386        );
1387
1388        let mut account = CashAccount::new(initial_event, false, false);
1389        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1390        account
1391            .update_balance_locked(instrument_id, Money::from("5000 USD"))
1392            .unwrap();
1393        assert_eq!(account.balances_locked.len(), 1);
1394
1395        let empty_event = AccountState::new(
1396            AccountId::from("SIM-001"),
1397            AccountType::Cash,
1398            vec![],
1399            vec![],
1400            true,
1401            uuid4(),
1402            1.into(),
1403            1.into(),
1404            Some(Currency::USD()),
1405        );
1406
1407        account.apply(empty_event).unwrap();
1408
1409        assert_eq!(account.balances_locked.len(), 1);
1410        assert_eq!(
1411            account.balance_total(Some(Currency::USD())),
1412            Some(Money::from("10000 USD"))
1413        );
1414        assert_eq!(account.event_count(), 2);
1415    }
1416}