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 self.calculate_account_state
215 }
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::{
384 CryptoFuture, CryptoPerpetual, CurrencyPair, Equity, Instrument, InstrumentAny,
385 stubs::*,
386 },
387 orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
388 position::Position,
389 types::{AccountBalance, Currency, Money, Price, Quantity},
390 };
391
392 #[rstest]
393 fn test_display(cash_account: CashAccount) {
394 assert_eq!(
395 format!("{cash_account}"),
396 "CashAccount(id=SIM-001, type=CASH, base=USD)"
397 );
398 }
399
400 #[rstest]
401 fn test_calculated_account_state_returns_field_value(cash_account_state: AccountState) {
402 assert!(
403 CashAccount::new(cash_account_state.clone(), true, false).calculated_account_state()
404 );
405 assert!(!CashAccount::new(cash_account_state, false, false).calculated_account_state());
406 }
407
408 #[rstest]
409 fn test_instantiate_single_asset_cash_account(
410 cash_account: CashAccount,
411 cash_account_state: AccountState,
412 ) {
413 assert_eq!(cash_account.id, AccountId::from("SIM-001"));
414 assert_eq!(cash_account.account_type, AccountType::Cash);
415 assert_eq!(cash_account.base_currency, Some(Currency::from("USD")));
416 assert_eq!(cash_account.last_event(), Some(cash_account_state.clone()));
417 assert_eq!(cash_account.events(), vec![cash_account_state]);
418 assert_eq!(cash_account.event_count(), 1);
419 assert_eq!(
420 cash_account.balance_total(None),
421 Some(Money::from("1525000 USD"))
422 );
423 assert_eq!(
424 cash_account.balance_free(None),
425 Some(Money::from("1500000 USD"))
426 );
427 assert_eq!(
428 cash_account.balance_locked(None),
429 Some(Money::from("25000 USD"))
430 );
431 let mut balances_total_expected = IndexMap::new();
432 balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
433 assert_eq!(cash_account.balances_total(), balances_total_expected);
434 let mut balances_free_expected = IndexMap::new();
435 balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
436 assert_eq!(cash_account.balances_free(), balances_free_expected);
437 let mut balances_locked_expected = IndexMap::new();
438 balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
439 assert_eq!(cash_account.balances_locked(), balances_locked_expected);
440 }
441
442 #[rstest]
443 fn test_instantiate_multi_asset_cash_account(
444 cash_account_multi: CashAccount,
445 cash_account_state_multi: AccountState,
446 ) {
447 assert_eq!(cash_account_multi.id, AccountId::from("SIM-001"));
448 assert_eq!(cash_account_multi.account_type, AccountType::Cash);
449 assert_eq!(
450 cash_account_multi.last_event(),
451 Some(cash_account_state_multi.clone())
452 );
453 assert_eq!(cash_account_state_multi.base_currency, None);
454 assert_eq!(cash_account_multi.events(), vec![cash_account_state_multi]);
455 assert_eq!(cash_account_multi.event_count(), 1);
456 assert_eq!(
457 cash_account_multi.balance_total(Some(Currency::BTC())),
458 Some(Money::from("10 BTC"))
459 );
460 assert_eq!(
461 cash_account_multi.balance_total(Some(Currency::ETH())),
462 Some(Money::from("20 ETH"))
463 );
464 assert_eq!(
465 cash_account_multi.balance_free(Some(Currency::BTC())),
466 Some(Money::from("10 BTC"))
467 );
468 assert_eq!(
469 cash_account_multi.balance_free(Some(Currency::ETH())),
470 Some(Money::from("20 ETH"))
471 );
472 assert_eq!(
473 cash_account_multi.balance_locked(Some(Currency::BTC())),
474 Some(Money::from("0 BTC"))
475 );
476 assert_eq!(
477 cash_account_multi.balance_locked(Some(Currency::ETH())),
478 Some(Money::from("0 ETH"))
479 );
480 let mut balances_total_expected = IndexMap::new();
481 balances_total_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
482 balances_total_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
483 assert_eq!(cash_account_multi.balances_total(), balances_total_expected);
484 let mut balances_free_expected = IndexMap::new();
485 balances_free_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
486 balances_free_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
487 assert_eq!(cash_account_multi.balances_free(), balances_free_expected);
488 let mut balances_locked_expected = IndexMap::new();
489 balances_locked_expected.insert(Currency::from("BTC"), Money::from("0 BTC"));
490 balances_locked_expected.insert(Currency::from("ETH"), Money::from("0 ETH"));
491 assert_eq!(
492 cash_account_multi.balances_locked(),
493 balances_locked_expected
494 );
495 }
496
497 #[rstest]
498 fn test_cash_account_balances_preserve_insertion_order(cash_account_multi: CashAccount) {
499 let keys: Vec<Currency> = cash_account_multi.balances().keys().copied().collect();
504 assert_eq!(keys, vec![Currency::from("BTC"), Currency::from("ETH")]);
505
506 let totals: Vec<(Currency, Money)> =
507 cash_account_multi.balances_total().into_iter().collect();
508 assert_eq!(
509 totals,
510 vec![
511 (Currency::from("BTC"), Money::from("10 BTC")),
512 (Currency::from("ETH"), Money::from("20 ETH")),
513 ]
514 );
515 }
516
517 #[rstest]
518 fn test_apply_given_new_state_event_updates_correctly(
519 mut cash_account_multi: CashAccount,
520 cash_account_state_multi: AccountState,
521 cash_account_state_multi_changed_btc: AccountState,
522 ) {
523 cash_account_multi
525 .apply(cash_account_state_multi_changed_btc.clone())
526 .unwrap();
527 assert_eq!(
528 cash_account_multi.last_event(),
529 Some(cash_account_state_multi_changed_btc.clone())
530 );
531 assert_eq!(
532 cash_account_multi.events,
533 vec![
534 cash_account_state_multi,
535 cash_account_state_multi_changed_btc
536 ]
537 );
538 assert_eq!(cash_account_multi.event_count(), 2);
539 assert_eq!(
540 cash_account_multi.balance_total(Some(Currency::BTC())),
541 Some(Money::from("9 BTC"))
542 );
543 assert_eq!(
544 cash_account_multi.balance_free(Some(Currency::BTC())),
545 Some(Money::from("8.5 BTC"))
546 );
547 assert_eq!(
548 cash_account_multi.balance_locked(Some(Currency::BTC())),
549 Some(Money::from("0.5 BTC"))
550 );
551 assert_eq!(
552 cash_account_multi.balance_total(Some(Currency::ETH())),
553 Some(Money::from("20 ETH"))
554 );
555 assert_eq!(
556 cash_account_multi.balance_free(Some(Currency::ETH())),
557 Some(Money::from("20 ETH"))
558 );
559 assert_eq!(
560 cash_account_multi.balance_locked(Some(Currency::ETH())),
561 Some(Money::from("0 ETH"))
562 );
563 }
564
565 #[rstest]
566 fn test_calculate_balance_locked_buy(
567 mut cash_account_million_usd: CashAccount,
568 audusd_sim: CurrencyPair,
569 ) {
570 let balance_locked = cash_account_million_usd
571 .calculate_balance_locked(
572 &audusd_sim.into_any(),
573 OrderSide::Buy,
574 Quantity::from("1000000"),
575 Price::from("0.8"),
576 None,
577 )
578 .unwrap();
579 assert_eq!(balance_locked, Money::from("800000 USD"));
580 }
581
582 #[rstest]
583 fn test_calculate_balance_locked_buy_quanto_uses_quote_currency(
584 mut cash_account_million_usd: CashAccount,
585 ethbtc_quanto: CryptoFuture,
586 ) {
587 let balance_locked = cash_account_million_usd
588 .calculate_balance_locked(
589 ðbtc_quanto.into_any(),
590 OrderSide::Buy,
591 Quantity::from("5"),
592 Price::from("0.036"),
593 None,
594 )
595 .unwrap();
596 assert_eq!(balance_locked, Money::from("0.18 BTC"));
597 }
598
599 #[rstest]
600 #[case(false, Money::from("0.002 BTC"))]
601 #[case(true, Money::from("100 USD"))]
602 fn test_calculate_balance_locked_buy_inverse_respects_quote_flag(
603 #[case] use_quote_for_inverse: bool,
604 #[case] expected: Money,
605 mut cash_account_million_usd: CashAccount,
606 xbtusd_inverse_perp: CryptoPerpetual,
607 ) {
608 let balance_locked = cash_account_million_usd
609 .calculate_balance_locked(
610 &xbtusd_inverse_perp.into_any(),
611 OrderSide::Buy,
612 Quantity::from("100"),
613 Price::from("50000"),
614 Some(use_quote_for_inverse),
615 )
616 .unwrap();
617 assert_eq!(balance_locked, expected);
618 }
619
620 #[rstest]
621 fn test_calculate_balance_locked_sell(
622 mut cash_account_million_usd: CashAccount,
623 audusd_sim: CurrencyPair,
624 ) {
625 let balance_locked = cash_account_million_usd
626 .calculate_balance_locked(
627 &audusd_sim.into_any(),
628 OrderSide::Sell,
629 Quantity::from("1000000"),
630 Price::from("0.8"),
631 None,
632 )
633 .unwrap();
634 assert_eq!(balance_locked, Money::from("1000000 AUD"));
635 }
636
637 #[rstest]
638 fn test_calculate_balance_locked_sell_no_base_currency(
639 mut cash_account_million_usd: CashAccount,
640 equity_aapl: Equity,
641 ) {
642 let balance_locked = cash_account_million_usd
643 .calculate_balance_locked(
644 &equity_aapl.into_any(),
645 OrderSide::Sell,
646 Quantity::from("100"),
647 Price::from("1500.0"),
648 None,
649 )
650 .unwrap();
651 assert_eq!(balance_locked, Money::from("100 USD"));
652 }
653
654 #[rstest]
655 fn test_calculate_pnls_for_single_currency_cash_account(
656 cash_account_million_usd: CashAccount,
657 audusd_sim: CurrencyPair,
658 ) {
659 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
660 let order = OrderTestBuilder::new(OrderType::Market)
661 .instrument_id(audusd_sim.id())
662 .side(OrderSide::Buy)
663 .quantity(Quantity::from("1000000"))
664 .build();
665 let fill = TestOrderEventStubs::filled(
666 &order,
667 &audusd_sim,
668 None,
669 Some(PositionId::new("P-123456")),
670 Some(Price::from("0.8")),
671 None,
672 None,
673 None,
674 None,
675 Some(AccountId::from("SIM-001")),
676 );
677 let position = Position::new(&audusd_sim, fill.clone().into());
678 let fill_owned: crate::events::OrderFilled = fill.into();
679 let pnls = cash_account_million_usd
680 .calculate_pnls(&audusd_sim, &fill_owned, Some(position))
681 .unwrap();
682 assert_eq!(pnls, vec![Money::from("-800000 USD")]);
683 }
684
685 #[rstest]
686 fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
687 cash_account_multi: CashAccount,
688 currency_pair_btcusdt: CurrencyPair,
689 ) {
690 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt.clone());
691 let order1 = OrderTestBuilder::new(OrderType::Market)
692 .instrument_id(currency_pair_btcusdt.id)
693 .side(OrderSide::Sell)
694 .quantity(Quantity::from("0.5"))
695 .build();
696 let fill1 = TestOrderEventStubs::filled(
697 &order1,
698 &btcusdt,
699 None,
700 Some(PositionId::new("P-123456")),
701 Some(Price::from("45500.00")),
702 None,
703 None,
704 None,
705 None,
706 Some(AccountId::from("SIM-001")),
707 );
708 let position = Position::new(&btcusdt, fill1.clone().into());
709 let fill1_owned: crate::events::OrderFilled = fill1.into();
710 let result1 = cash_account_multi
711 .calculate_pnls(&btcusdt, &fill1_owned, Some(position.clone()))
712 .unwrap();
713 let order2 = OrderTestBuilder::new(OrderType::Market)
714 .instrument_id(currency_pair_btcusdt.id)
715 .side(OrderSide::Buy)
716 .quantity(Quantity::from("0.5"))
717 .build();
718 let fill2 = TestOrderEventStubs::filled(
719 &order2,
720 &btcusdt,
721 None,
722 Some(PositionId::new("P-123456")),
723 Some(Price::from("45500.00")),
724 None,
725 None,
726 None,
727 None,
728 Some(AccountId::from("SIM-001")),
729 );
730 let fill2_owned: crate::events::OrderFilled = fill2.into();
731 let result2 = cash_account_multi
732 .calculate_pnls(
733 ¤cy_pair_btcusdt.into_any(),
734 &fill2_owned,
735 Some(position),
736 )
737 .unwrap();
738 let result1_set: AHashSet<Money> = result1.into_iter().collect();
740 let result1_expected: AHashSet<Money> =
741 vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
742 .into_iter()
743 .collect();
744 let result2_set: AHashSet<Money> = result2.into_iter().collect();
745 let result2_expected: AHashSet<Money> =
746 vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
747 .into_iter()
748 .collect();
749 assert_eq!(result1_set, result1_expected);
750 assert_eq!(result2_set, result2_expected);
751 }
752
753 #[rstest]
754 #[case(false, Money::from("-0.00218331 BTC"))]
755 #[case(true, Money::from("-25.0 USD"))]
756 fn test_calculate_commission_for_inverse_maker_crypto(
757 #[case] use_quote_for_inverse: bool,
758 #[case] expected: Money,
759 cash_account_million_usd: CashAccount,
760 xbtusd_bitmex: CryptoPerpetual,
761 ) {
762 let result = cash_account_million_usd
763 .calculate_commission(
764 &xbtusd_bitmex.into_any(),
765 Quantity::from("100000"),
766 Price::from("11450.50"),
767 LiquiditySide::Maker,
768 Some(use_quote_for_inverse),
769 )
770 .unwrap();
771 assert_eq!(result, expected);
772 }
773
774 #[rstest]
775 fn test_calculate_commission_for_taker_fx(
776 cash_account_million_usd: CashAccount,
777 audusd_sim: CurrencyPair,
778 ) {
779 let result = cash_account_million_usd
780 .calculate_commission(
781 &audusd_sim.into_any(),
782 Quantity::from("1500000"),
783 Price::from("0.8005"),
784 LiquiditySide::Taker,
785 None,
786 )
787 .unwrap();
788 assert_eq!(result, Money::from("24.02 USD"));
789 }
790
791 #[rstest]
792 fn test_calculate_commission_crypto_taker(
793 cash_account_million_usd: CashAccount,
794 xbtusd_bitmex: CryptoPerpetual,
795 ) {
796 let result = cash_account_million_usd
797 .calculate_commission(
798 &xbtusd_bitmex.into_any(),
799 Quantity::from("100000"),
800 Price::from("11450.50"),
801 LiquiditySide::Taker,
802 None,
803 )
804 .unwrap();
805 assert_eq!(result, Money::from("0.00654993 BTC"));
806 }
807
808 #[rstest]
809 fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
810 let instrument = usdjpy_idealpro();
811 let result = cash_account_million_usd
812 .calculate_commission(
813 &instrument.into_any(),
814 Quantity::from("2200000"),
815 Price::from("120.310"),
816 LiquiditySide::Taker,
817 None,
818 )
819 .unwrap();
820 assert_eq!(result, Money::from("5294 JPY"));
821 }
822
823 #[rstest]
824 fn test_update_balance_locked_per_instrument_currency(
825 mut cash_account_multi: CashAccount,
826 currency_pair_btcusdt: CurrencyPair,
827 ) {
828 assert!(cash_account_multi.balances_locked.is_empty());
829
830 let instrument_id = currency_pair_btcusdt.id;
831
832 let usdt_lock = Money::from("1000 USDT");
833 cash_account_multi.update_balance_locked(instrument_id, usdt_lock);
834
835 let btc_lock = Money::from("0.5 BTC");
836 cash_account_multi.update_balance_locked(instrument_id, btc_lock);
837 assert_eq!(cash_account_multi.balances_locked.len(), 2);
838 assert_eq!(
839 cash_account_multi
840 .balances_locked
841 .get(&(instrument_id, Currency::USDT())),
842 Some(&usdt_lock)
843 );
844 assert_eq!(
845 cash_account_multi
846 .balances_locked
847 .get(&(instrument_id, Currency::BTC())),
848 Some(&btc_lock)
849 );
850 }
851
852 #[rstest]
853 fn test_clear_balance_locked_removes_all_currencies_for_instrument(
854 mut cash_account_multi: CashAccount,
855 currency_pair_btcusdt: CurrencyPair,
856 ) {
857 let instrument_id = currency_pair_btcusdt.id;
858
859 cash_account_multi.update_balance_locked(instrument_id, Money::from("1000 USDT"));
860 cash_account_multi.update_balance_locked(instrument_id, Money::from("0.5 BTC"));
861 assert_eq!(cash_account_multi.balances_locked.len(), 2);
862
863 cash_account_multi.clear_balance_locked(instrument_id);
864
865 assert!(cash_account_multi.balances_locked.is_empty());
866 }
867
868 #[rstest]
869 fn test_clear_balance_locked_only_removes_target_instrument(
870 mut cash_account_multi: CashAccount,
871 currency_pair_btcusdt: CurrencyPair,
872 ) {
873 let btcusdt_id = currency_pair_btcusdt.id;
874 let ethusdt_id = InstrumentId::from("ETHUSDT.BINANCE");
875
876 cash_account_multi.update_balance_locked(btcusdt_id, Money::from("1000 USDT"));
877 cash_account_multi.update_balance_locked(ethusdt_id, Money::from("500 USDT"));
878 assert_eq!(cash_account_multi.balances_locked.len(), 2);
879
880 cash_account_multi.clear_balance_locked(btcusdt_id);
881 assert_eq!(cash_account_multi.balances_locked.len(), 1);
882 assert_eq!(
883 cash_account_multi
884 .balances_locked
885 .get(&(ethusdt_id, Currency::USDT())),
886 Some(&Money::from("500 USDT"))
887 );
888 }
889
890 #[rstest]
891 fn test_recalculate_balance_clamps_when_locked_exceeds_total(
892 mut cash_account_multi: CashAccount,
893 currency_pair_btcusdt: CurrencyPair,
894 ) {
895 let initial_balance = *cash_account_multi.balance(Some(Currency::BTC())).unwrap();
896 assert_eq!(initial_balance.total, Money::from("10 BTC"));
897
898 let instrument_id = currency_pair_btcusdt.id;
900 cash_account_multi.update_balance_locked(instrument_id, Money::from("15 BTC"));
901
902 let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
903 assert_eq!(balance.total, Money::from("10 BTC"));
904 assert_eq!(balance.locked, Money::from("10 BTC"));
905 assert_eq!(balance.free, Money::from("0 BTC"));
906 }
907
908 #[rstest]
909 fn test_recalculate_balance_sums_multiple_instrument_locks(
910 mut cash_account_multi: CashAccount,
911 ) {
912 let btcusdt_id = InstrumentId::from("BTCUSDT.BINANCE");
913 let btceth_id = InstrumentId::from("BTCETH.BINANCE");
914
915 cash_account_multi.update_balance_locked(btcusdt_id, Money::from("3 BTC"));
916 cash_account_multi.update_balance_locked(btceth_id, Money::from("2 BTC"));
917
918 let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
919 assert_eq!(balance.total, Money::from("10 BTC"));
920 assert_eq!(balance.locked, Money::from("5 BTC"));
921 assert_eq!(balance.free, Money::from("5 BTC"));
922 }
923
924 #[rstest]
925 fn test_recalculate_balance_no_clamp_when_total_negative_borrowing() {
926 let negative_balance_event = AccountState::new(
928 AccountId::from("SIM-001"),
929 AccountType::Cash,
930 vec![AccountBalance::new(
931 Money::from("-1000 USD"), Money::from("0 USD"),
933 Money::from("-1000 USD"),
934 )],
935 vec![],
936 true,
937 uuid4(),
938 0.into(),
939 0.into(),
940 Some(Currency::USD()),
941 );
942
943 let mut account = CashAccount::new(negative_balance_event, false, true);
944 let instrument_id = InstrumentId::from("EURUSD.SIM");
945
946 account.update_balance_locked(instrument_id, Money::from("500 USD"));
947
948 let balance = account.balance(Some(Currency::USD())).unwrap();
950 assert_eq!(balance.total, Money::from("-1000 USD"));
951 assert_eq!(balance.locked, Money::from("500 USD"));
952 assert_eq!(balance.free, Money::from("-1500 USD"));
953 }
954
955 #[rstest]
956 fn test_apply_returns_error_when_negative_balance_and_borrowing_disabled() {
957 let initial_event = AccountState::new(
958 AccountId::from("SIM-001"),
959 AccountType::Cash,
960 vec![AccountBalance::new(
961 Money::from("1000 USD"),
962 Money::from("0 USD"),
963 Money::from("1000 USD"),
964 )],
965 vec![],
966 true,
967 uuid4(),
968 0.into(),
969 0.into(),
970 Some(Currency::USD()),
971 );
972
973 let mut account = CashAccount::new(initial_event, false, false);
974
975 let negative_balance_event = AccountState::new(
976 AccountId::from("SIM-001"),
977 AccountType::Cash,
978 vec![AccountBalance::new(
979 Money::from("-500 USD"),
980 Money::from("0 USD"),
981 Money::from("-500 USD"),
982 )],
983 vec![],
984 true,
985 uuid4(),
986 1.into(),
987 1.into(),
988 Some(Currency::USD()),
989 );
990
991 let result = account.apply(negative_balance_event);
992
993 assert!(result.is_err());
994 let err_msg = result.unwrap_err().to_string();
995 assert!(err_msg.contains("negative"));
996 assert!(err_msg.contains("borrowing not allowed"));
997 }
998
999 #[rstest]
1000 fn test_apply_succeeds_when_negative_balance_and_borrowing_enabled() {
1001 let initial_event = AccountState::new(
1002 AccountId::from("SIM-001"),
1003 AccountType::Cash,
1004 vec![AccountBalance::new(
1005 Money::from("1000 USD"),
1006 Money::from("0 USD"),
1007 Money::from("1000 USD"),
1008 )],
1009 vec![],
1010 true,
1011 uuid4(),
1012 0.into(),
1013 0.into(),
1014 Some(Currency::USD()),
1015 );
1016
1017 let mut account = CashAccount::new(initial_event, false, true);
1018
1019 let negative_balance_event = AccountState::new(
1020 AccountId::from("SIM-001"),
1021 AccountType::Cash,
1022 vec![AccountBalance::new(
1023 Money::from("-500 USD"),
1024 Money::from("0 USD"),
1025 Money::from("-500 USD"),
1026 )],
1027 vec![],
1028 true,
1029 uuid4(),
1030 1.into(),
1031 1.into(),
1032 Some(Currency::USD()),
1033 );
1034
1035 let result = account.apply(negative_balance_event);
1036
1037 assert!(result.is_ok());
1038 assert_eq!(
1039 account.balance_total(Some(Currency::USD())),
1040 Some(Money::from("-500 USD"))
1041 );
1042 }
1043
1044 #[rstest]
1045 fn test_apply_clears_per_instrument_locks() {
1046 let initial_event = AccountState::new(
1047 AccountId::from("SIM-001"),
1048 AccountType::Cash,
1049 vec![AccountBalance::new(
1050 Money::from("10000 USD"),
1051 Money::from("0 USD"),
1052 Money::from("10000 USD"),
1053 )],
1054 vec![],
1055 true,
1056 uuid4(),
1057 0.into(),
1058 0.into(),
1059 Some(Currency::USD()),
1060 );
1061
1062 let mut account = CashAccount::new(initial_event, false, false);
1063 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1064
1065 account.update_balance_locked(instrument_id, Money::from("5000 USD"));
1067 assert_eq!(account.balances_locked.len(), 1);
1068
1069 let new_event = AccountState::new(
1071 AccountId::from("SIM-001"),
1072 AccountType::Cash,
1073 vec![AccountBalance::new(
1074 Money::from("8000 USD"),
1075 Money::from("0 USD"),
1076 Money::from("8000 USD"),
1077 )],
1078 vec![],
1079 true,
1080 uuid4(),
1081 1.into(),
1082 1.into(),
1083 Some(Currency::USD()),
1084 );
1085
1086 account.apply(new_event).unwrap();
1087
1088 assert!(account.balances_locked.is_empty());
1089 assert_eq!(
1090 account.balance_total(Some(Currency::USD())),
1091 Some(Money::from("8000 USD"))
1092 );
1093 }
1094
1095 #[rstest]
1096 fn test_apply_empty_balances_preserves_per_instrument_locks() {
1097 let initial_event = AccountState::new(
1098 AccountId::from("SIM-001"),
1099 AccountType::Cash,
1100 vec![AccountBalance::new(
1101 Money::from("10000 USD"),
1102 Money::from("0 USD"),
1103 Money::from("10000 USD"),
1104 )],
1105 vec![],
1106 true,
1107 uuid4(),
1108 0.into(),
1109 0.into(),
1110 Some(Currency::USD()),
1111 );
1112
1113 let mut account = CashAccount::new(initial_event, false, false);
1114 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1115 account.update_balance_locked(instrument_id, Money::from("5000 USD"));
1116 assert_eq!(account.balances_locked.len(), 1);
1117
1118 let empty_event = AccountState::new(
1119 AccountId::from("SIM-001"),
1120 AccountType::Cash,
1121 vec![],
1122 vec![],
1123 true,
1124 uuid4(),
1125 1.into(),
1126 1.into(),
1127 Some(Currency::USD()),
1128 );
1129
1130 account.apply(empty_event).unwrap();
1131
1132 assert_eq!(account.balances_locked.len(), 1);
1133 assert_eq!(
1134 account.balance_total(Some(Currency::USD())),
1135 Some(Money::from("10000 USD"))
1136 );
1137 assert_eq!(account.event_count(), 2);
1138 }
1139}