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