1use 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 #[serde(skip, default)]
63 pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
64}
65
66impl CashAccount {
67 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 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 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 ¤cies_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 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 pub fn recalculate_balance(&mut self, currency: Currency) {
155 let current_balance = match self.balances.get(¤cy) {
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 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 }
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 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, fill: OrderFilled, 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 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)) .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(), 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 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 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 let negative_balance_event = AccountState::new(
857 AccountId::from("SIM-001"),
858 AccountType::Cash,
859 vec![AccountBalance::new(
860 Money::from("-1000 USD"), 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 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 account.update_balance_locked(instrument_id, Money::from("5000 USD"));
996 assert_eq!(account.balances_locked.len(), 1);
997
998 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}