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