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