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