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::*, order::spec::OrderFilledSpec},
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 #[case(OrderSide::Buy, "0.01", "0.001234 USD")]
557 #[case(OrderSide::Sell, "0.0001", "0.0001 AUD")]
558 fn test_balance_locked_preserves_native_precision(
559 mut cash_account_million_usd: CashAccount,
560 audusd_sim: CurrencyPair,
561 #[case] side: OrderSide,
562 #[case] qty: &str,
563 #[case] expected: &str,
564 ) {
565 let instrument = audusd_sim.into_any();
566 let locked = cash_account_million_usd
567 .calculate_balance_locked(
568 &instrument,
569 side,
570 Quantity::from(qty),
571 Price::from("0.1234"),
572 None,
573 )
574 .unwrap();
575 cash_account_million_usd
576 .update_balance_locked(instrument.id(), locked)
577 .unwrap();
578 let expected = Money::from_str_exact(expected).unwrap();
579
580 assert_eq!(locked, expected);
581 assert_eq!(
582 cash_account_million_usd.balances_locked[&(instrument.id(), expected.currency)],
583 expected,
584 );
585
586 if side == OrderSide::Buy {
587 assert_eq!(
588 cash_account_million_usd
589 .balance(Some(Currency::USD()))
590 .copied(),
591 Some(AccountBalance::new(
592 Money::from("1000000 USD"),
593 expected,
594 Money::from_str_exact("999999.998766 USD").unwrap(),
595 )),
596 );
597 }
598 }
599
600 #[rstest]
601 #[case::profit("0 USD", "1000000.000001 USD")]
602 #[case::fee("0.000001 USD", "1000000 USD")]
603 fn test_fill_cash_flows_preserve_micro_profit_and_fee(
604 mut cash_account_million_usd: CashAccount,
605 audusd_sim: CurrencyPair,
606 #[case] fee: &str,
607 #[case] expected: &str,
608 ) {
609 let instrument = audusd_sim.into_any();
610 let fee = Money::from_str_exact(fee).unwrap();
611 let mut cash_flows = Vec::new();
612
613 for (side, price, commission) in [
614 (OrderSide::Buy, "0.1234", fee),
615 (OrderSide::Sell, "0.1235", Money::zero(Currency::USD())),
616 ] {
617 let fill = OrderFilledSpec::builder()
618 .instrument_id(instrument.id())
619 .order_side(side)
620 .last_qty(Quantity::from("0.01"))
621 .last_px(Price::from(price))
622 .commission(commission)
623 .build();
624 let pnls = cash_account_million_usd
625 .calculate_pnls(&instrument, &fill, None)
626 .unwrap();
627 let current = cash_account_million_usd.balance_total(None).unwrap();
628 let total = current
629 .checked_add(pnls[0])
630 .unwrap()
631 .checked_sub(commission)
632 .unwrap();
633 let balance = AccountBalance::new(total, Money::zero(Currency::USD()), total);
634 cash_account_million_usd
635 .update_balances(&[balance])
636 .unwrap();
637 cash_flows.extend(pnls);
638 }
639
640 let expected = Money::from_str_exact(expected).unwrap();
641 assert_eq!(
642 cash_flows,
643 vec![
644 Money::from_str_exact("-0.001234 USD").unwrap(),
645 Money::from_str_exact("0.001235 USD").unwrap(),
646 ],
647 );
648 assert_eq!(
649 cash_account_million_usd.balance(None).copied(),
650 Some(AccountBalance::new(
651 expected,
652 Money::zero(Currency::USD()),
653 expected
654 )),
655 );
656 }
657
658 #[rstest]
659 fn test_calculate_balance_locked_buy_returns_error_for_unrepresentable_notional(
660 cash_account_million_usd: CashAccount,
661 audusd_sim: CurrencyPair,
662 ) {
663 let result = cash_account_million_usd.calculate_balance_locked(
664 &audusd_sim.into_any(),
665 OrderSide::Buy,
666 Quantity::from("100000000"),
667 Price::from("100000000"),
668 None,
669 );
670
671 assert!(result.is_err());
672 }
673
674 #[rstest]
675 fn test_calculate_balance_locked_buy_quanto_uses_settlement_currency(
676 cash_account_million_usd: CashAccount,
677 ethbtc_quanto: CryptoFuture,
678 ) {
679 let balance_locked = cash_account_million_usd
680 .calculate_balance_locked(
681 ðbtc_quanto.into_any(),
682 OrderSide::Buy,
683 Quantity::from("5"),
684 Price::from("0.036"),
685 None,
686 )
687 .unwrap();
688 assert_eq!(balance_locked, Money::from("0.18 USDT"));
689 }
690
691 #[rstest]
692 #[case(false, Money::from("0.002 BTC"))]
693 #[case(true, Money::from("100 USD"))]
694 fn test_calculate_balance_locked_buy_inverse_respects_quote_flag(
695 #[case] use_quote_for_inverse: bool,
696 #[case] expected: Money,
697 cash_account_million_usd: CashAccount,
698 btcusd_inverse_perp: CryptoPerpetual,
699 ) {
700 let balance_locked = cash_account_million_usd
701 .calculate_balance_locked(
702 &btcusd_inverse_perp.into_any(),
703 OrderSide::Buy,
704 Quantity::from("100"),
705 Price::from("50000"),
706 Some(use_quote_for_inverse),
707 )
708 .unwrap();
709 assert_eq!(balance_locked, expected);
710 }
711
712 #[rstest]
713 fn test_calculate_balance_locked_sell(
714 cash_account_million_usd: CashAccount,
715 audusd_sim: CurrencyPair,
716 ) {
717 let balance_locked = cash_account_million_usd
718 .calculate_balance_locked(
719 &audusd_sim.into_any(),
720 OrderSide::Sell,
721 Quantity::from("1000000"),
722 Price::from("0.8"),
723 None,
724 )
725 .unwrap();
726 assert_eq!(balance_locked, Money::from("1000000 AUD"));
727 }
728
729 #[rstest]
730 fn test_calculate_balance_locked_sell_no_base_currency(
731 cash_account_million_usd: CashAccount,
732 equity_aapl: Equity,
733 ) {
734 let balance_locked = cash_account_million_usd
735 .calculate_balance_locked(
736 &equity_aapl.into_any(),
737 OrderSide::Sell,
738 Quantity::from("100"),
739 Price::from("1500.0"),
740 None,
741 )
742 .unwrap();
743 assert_eq!(balance_locked, Money::zero(Currency::USD()));
744 }
745
746 #[rstest]
747 fn test_calculate_balance_locked_sell_binary_option_reserves_nothing(
748 cash_account_million_usd: CashAccount,
749 binary_option: BinaryOption,
750 ) {
751 let balance_locked = cash_account_million_usd
754 .calculate_balance_locked(
755 &InstrumentAny::BinaryOption(binary_option),
756 OrderSide::Sell,
757 Quantity::from("5.00"),
758 Price::from("0.500"),
759 None,
760 )
761 .unwrap();
762 assert_eq!(balance_locked, Money::zero(Currency::USDC()));
763 }
764
765 #[rstest]
766 fn test_calculate_balance_locked_buy_no_base_currency_still_reserves_notional(
767 cash_account_million_usd: CashAccount,
768 binary_option: BinaryOption,
769 ) {
770 let balance_locked = cash_account_million_usd
771 .calculate_balance_locked(
772 &binary_option.into_any(),
773 OrderSide::Buy,
774 Quantity::from("5"),
775 Price::from("0.500"),
776 None,
777 )
778 .unwrap();
779 assert_eq!(balance_locked, Money::new(2.5, Currency::USDC()));
780 }
781
782 #[rstest]
783 fn test_calculate_pnls_for_single_currency_cash_account(
784 cash_account_million_usd: CashAccount,
785 audusd_sim: CurrencyPair,
786 ) {
787 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
788 let order = OrderTestBuilder::new(OrderType::Market)
789 .instrument_id(audusd_sim.id())
790 .side(OrderSide::Buy)
791 .quantity(Quantity::from("1000000"))
792 .build();
793 let fill = TestOrderEventStubs::filled(
794 &order,
795 &audusd_sim,
796 None,
797 Some(PositionId::new("P-123456")),
798 Some(Price::from("0.8")),
799 None,
800 None,
801 None,
802 None,
803 Some(AccountId::from("SIM-001")),
804 );
805 let position = Position::new(&audusd_sim, fill.clone().into());
806 let fill_owned: crate::events::OrderFilled = fill.into();
807 let pnls = cash_account_million_usd
808 .calculate_pnls(&audusd_sim, &fill_owned, Some(position))
809 .unwrap();
810 assert_eq!(pnls, vec![Money::from("-800000 USD")]);
811 }
812
813 #[cfg(feature = "defi")]
814 #[rstest]
815 fn test_fill_cash_flows_reject_unrepresentable_foreign_commission(
816 cash_account: CashAccount,
817 audusd_sim: CurrencyPair,
818 ) {
819 let instrument = audusd_sim.into_any();
820 let fill = OrderFilledSpec::builder()
821 .instrument_id(instrument.id())
822 .last_qty(Quantity::from("0.01"))
823 .last_px(Price::from("0.1234"))
824 .commission(Money::from_raw(
825 100_000_000_000_000_000_000_000_000_001,
826 Currency::EUR(),
827 ))
828 .build();
829
830 let error = cash_account
831 .calculate_pnls(&instrument, &fill, None)
832 .unwrap_err();
833
834 assert_eq!(
835 error.to_string(),
836 "position input exceeds exact Decimal representation"
837 );
838 }
839
840 #[rstest]
841 fn test_fill_cash_flows_preserve_sub_cent_base_and_quote(
842 cash_account_multi: CashAccount,
843 audusd_sim: CurrencyPair,
844 ) {
845 let instrument = audusd_sim.into_any();
846 let fill = OrderFilledSpec::builder()
847 .instrument_id(instrument.id())
848 .order_side(OrderSide::Buy)
849 .last_qty(Quantity::from("0.0001"))
850 .last_px(Price::from("0.1234"))
851 .commission(Money::zero(Currency::USD()))
852 .build();
853
854 let pnls = cash_account_multi
855 .calculate_pnls(&instrument, &fill, None)
856 .unwrap();
857
858 assert_eq!(
859 pnls,
860 vec![
861 Money::from_str_exact("0.0001 AUD").unwrap(),
862 Money::from_str_exact("-0.00001234 USD").unwrap(),
863 ],
864 );
865 }
866
867 #[rstest]
868 fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
869 cash_account_multi: CashAccount,
870 currency_pair_btcusdt: CurrencyPair,
871 ) {
872 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt.clone());
873 let order1 = OrderTestBuilder::new(OrderType::Market)
874 .instrument_id(currency_pair_btcusdt.id)
875 .side(OrderSide::Sell)
876 .quantity(Quantity::from("0.5"))
877 .build();
878 let fill1 = TestOrderEventStubs::filled(
879 &order1,
880 &btcusdt,
881 None,
882 Some(PositionId::new("P-123456")),
883 Some(Price::from("45500.00")),
884 None,
885 None,
886 None,
887 None,
888 Some(AccountId::from("SIM-001")),
889 );
890 let position = Position::new(&btcusdt, fill1.clone().into());
891 let fill1_owned: crate::events::OrderFilled = fill1.into();
892 let result1 = cash_account_multi
893 .calculate_pnls(&btcusdt, &fill1_owned, Some(position.clone()))
894 .unwrap();
895 let order2 = OrderTestBuilder::new(OrderType::Market)
896 .instrument_id(currency_pair_btcusdt.id)
897 .side(OrderSide::Buy)
898 .quantity(Quantity::from("0.5"))
899 .build();
900 let fill2 = TestOrderEventStubs::filled(
901 &order2,
902 &btcusdt,
903 None,
904 Some(PositionId::new("P-123456")),
905 Some(Price::from("45500.00")),
906 None,
907 None,
908 None,
909 None,
910 Some(AccountId::from("SIM-001")),
911 );
912 let fill2_owned: crate::events::OrderFilled = fill2.into();
913 let result2 = cash_account_multi
914 .calculate_pnls(
915 ¤cy_pair_btcusdt.into_any(),
916 &fill2_owned,
917 Some(position),
918 )
919 .unwrap();
920 let result1_set: AHashSet<Money> = result1.into_iter().collect();
922 let result1_expected: AHashSet<Money> =
923 vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
924 .into_iter()
925 .collect();
926 let result2_set: AHashSet<Money> = result2.into_iter().collect();
927 let result2_expected: AHashSet<Money> =
928 vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
929 .into_iter()
930 .collect();
931 assert_eq!(result1_set, result1_expected);
932 assert_eq!(result2_set, result2_expected);
933 }
934
935 #[rstest]
936 #[case(false, Money::from("-0.00218331 BTC"))]
937 #[case(true, Money::from("-25.0 USD"))]
938 fn test_calculate_commission_for_inverse_maker_crypto(
939 #[case] use_quote_for_inverse: bool,
940 #[case] expected: Money,
941 cash_account_million_usd: CashAccount,
942 btcusd_bybit: CryptoPerpetual,
943 ) {
944 let fee_rates = MakerTakerFeeRates::new(dec!(-0.00025), dec!(0.00075));
945 let result = cash_account_million_usd
946 .calculate_commission(
947 &btcusd_bybit.into_any(),
948 Quantity::from("100000"),
949 Price::from("11450.50"),
950 LiquiditySide::Maker,
951 fee_rates,
952 Some(use_quote_for_inverse),
953 )
954 .unwrap();
955 assert_eq!(result, expected);
956 }
957
958 #[rstest]
959 fn test_calculate_commission_for_taker_fx(
960 cash_account_million_usd: CashAccount,
961 audusd_sim: CurrencyPair,
962 ) {
963 let fee_rates = MakerTakerFeeRates::new(dec!(0.00002), dec!(0.00002));
964 let result = cash_account_million_usd
965 .calculate_commission(
966 &audusd_sim.into_any(),
967 Quantity::from("1500000"),
968 Price::from("0.8005"),
969 LiquiditySide::Taker,
970 fee_rates,
971 None,
972 )
973 .unwrap();
974 assert_eq!(result, Money::from("24.02 USD"));
975 }
976
977 #[rstest]
978 fn test_calculate_commission_crypto_taker(
979 cash_account_million_usd: CashAccount,
980 btcusd_bybit: CryptoPerpetual,
981 ) {
982 let fee_rates = MakerTakerFeeRates::new(dec!(-0.00025), dec!(0.00075));
983 let result = cash_account_million_usd
984 .calculate_commission(
985 &btcusd_bybit.into_any(),
986 Quantity::from("100000"),
987 Price::from("11450.50"),
988 LiquiditySide::Taker,
989 fee_rates,
990 None,
991 )
992 .unwrap();
993 assert_eq!(result, Money::from("0.00654993 BTC"));
994 }
995
996 #[rstest]
997 fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
998 let instrument = usdjpy_idealpro();
999 let fee_rates = MakerTakerFeeRates::new(dec!(0.00002), dec!(0.00002));
1000 let result = cash_account_million_usd
1001 .calculate_commission(
1002 &instrument.into_any(),
1003 Quantity::from("2200000"),
1004 Price::from("120.310"),
1005 LiquiditySide::Taker,
1006 fee_rates,
1007 None,
1008 )
1009 .unwrap();
1010 assert_eq!(result, Money::from("5294 JPY"));
1011 }
1012
1013 #[rstest]
1014 fn test_update_balance_locked_per_instrument_currency(
1015 mut cash_account_multi: CashAccount,
1016 currency_pair_btcusdt: CurrencyPair,
1017 ) {
1018 assert!(cash_account_multi.balances_locked.is_empty());
1019
1020 let instrument_id = currency_pair_btcusdt.id;
1021
1022 let usdt_lock = Money::from("1000 USDT");
1023 cash_account_multi
1024 .update_balance_locked(instrument_id, usdt_lock)
1025 .unwrap();
1026
1027 let btc_lock = Money::from("0.5 BTC");
1028 cash_account_multi
1029 .update_balance_locked(instrument_id, btc_lock)
1030 .unwrap();
1031 assert_eq!(cash_account_multi.balances_locked.len(), 2);
1032 assert_eq!(
1033 cash_account_multi
1034 .balances_locked
1035 .get(&(instrument_id, Currency::USDT())),
1036 Some(&usdt_lock)
1037 );
1038 assert_eq!(
1039 cash_account_multi
1040 .balances_locked
1041 .get(&(instrument_id, Currency::BTC())),
1042 Some(&btc_lock)
1043 );
1044 }
1045
1046 #[rstest]
1047 fn test_clear_balance_locked_removes_all_currencies_for_instrument(
1048 mut cash_account_multi: CashAccount,
1049 currency_pair_btcusdt: CurrencyPair,
1050 ) {
1051 let instrument_id = currency_pair_btcusdt.id;
1052
1053 cash_account_multi
1054 .update_balance_locked(instrument_id, Money::from("1000 USDT"))
1055 .unwrap();
1056 cash_account_multi
1057 .update_balance_locked(instrument_id, Money::from("0.5 BTC"))
1058 .unwrap();
1059 assert_eq!(cash_account_multi.balances_locked.len(), 2);
1060
1061 cash_account_multi.clear_balance_locked(instrument_id);
1062
1063 assert!(cash_account_multi.balances_locked.is_empty());
1064 }
1065
1066 #[rstest]
1067 fn test_clear_balance_locked_only_removes_target_instrument(
1068 mut cash_account_multi: CashAccount,
1069 currency_pair_btcusdt: CurrencyPair,
1070 ) {
1071 let btcusdt_id = currency_pair_btcusdt.id;
1072 let ethusdt_id = InstrumentId::from("ETHUSDT.BINANCE");
1073
1074 cash_account_multi
1075 .update_balance_locked(btcusdt_id, Money::from("1000 USDT"))
1076 .unwrap();
1077 cash_account_multi
1078 .update_balance_locked(ethusdt_id, Money::from("500 USDT"))
1079 .unwrap();
1080 assert_eq!(cash_account_multi.balances_locked.len(), 2);
1081
1082 cash_account_multi.clear_balance_locked(btcusdt_id);
1083 assert_eq!(cash_account_multi.balances_locked.len(), 1);
1084 assert_eq!(
1085 cash_account_multi
1086 .balances_locked
1087 .get(&(ethusdt_id, Currency::USDT())),
1088 Some(&Money::from("500 USDT"))
1089 );
1090 }
1091
1092 #[rstest]
1093 fn test_recalculate_balance_clamps_when_locked_exceeds_total(
1094 mut cash_account_multi: CashAccount,
1095 currency_pair_btcusdt: CurrencyPair,
1096 ) {
1097 let initial_balance = *cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1098 assert_eq!(initial_balance.total, Money::from("10 BTC"));
1099
1100 let instrument_id = currency_pair_btcusdt.id;
1102 cash_account_multi
1103 .update_balance_locked(instrument_id, Money::from("15 BTC"))
1104 .unwrap();
1105
1106 let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1107 assert_eq!(balance.total, Money::from("10 BTC"));
1108 assert_eq!(balance.locked, Money::from("10 BTC"));
1109 assert_eq!(balance.free, Money::from("0 BTC"));
1110 }
1111
1112 #[rstest]
1113 fn test_recalculate_balance_sums_multiple_instrument_locks(
1114 mut cash_account_multi: CashAccount,
1115 ) {
1116 let btcusdt_id = InstrumentId::from("BTCUSDT.BINANCE");
1117 let btceth_id = InstrumentId::from("BTCETH.BINANCE");
1118
1119 cash_account_multi
1120 .update_balance_locked(btcusdt_id, Money::from("3 BTC"))
1121 .unwrap();
1122 cash_account_multi
1123 .update_balance_locked(btceth_id, Money::from("2 BTC"))
1124 .unwrap();
1125
1126 let balance = cash_account_multi.balance(Some(Currency::BTC())).unwrap();
1127 assert_eq!(balance.total, Money::from("10 BTC"));
1128 assert_eq!(balance.locked, Money::from("5 BTC"));
1129 assert_eq!(balance.free, Money::from("5 BTC"));
1130 }
1131
1132 #[rstest]
1133 fn test_update_balance_locked_precision_mismatch_preserves_state(
1134 mut cash_account_multi: CashAccount,
1135 ) {
1136 let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1137 let btc = Currency::BTC();
1138 cash_account_multi
1139 .update_balance_locked(instrument_id, Money::from("3 BTC"))
1140 .unwrap();
1141 let balance_before = *cash_account_multi.balance(Some(btc)).unwrap();
1142 let locks_before = cash_account_multi.balances_locked.clone();
1143 let mismatched_btc =
1144 Currency::new("BTC", btc.precision - 1, 0, "Bitcoin", CurrencyType::Crypto);
1145 let locked = Money::from_decimal(Decimal::from(2), mismatched_btc).unwrap();
1146
1147 let error = cash_account_multi
1148 .update_balance_locked(instrument_id, locked)
1149 .unwrap_err();
1150
1151 assert_eq!(
1152 error.to_string(),
1153 "Cannot update BTC reservation: precision 7 differed from balance precision 8"
1154 );
1155 assert_eq!(cash_account_multi.balance(Some(btc)), Some(&balance_before));
1156 assert_eq!(cash_account_multi.balances_locked, locks_before);
1157 }
1158
1159 #[rstest]
1160 fn test_recalculate_balance_no_clamp_when_total_negative_borrowing() {
1161 let negative_balance_event = AccountState::new(
1163 AccountId::from("SIM-001"),
1164 AccountType::Cash,
1165 vec![AccountBalance::new(
1166 Money::from("-1000 USD"), Money::from("0 USD"),
1168 Money::from("-1000 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(negative_balance_event, false, true);
1179 let instrument_id = InstrumentId::from("EURUSD.SIM");
1180
1181 account
1182 .update_balance_locked(instrument_id, Money::from("500 USD"))
1183 .unwrap();
1184
1185 let balance = account.balance(Some(Currency::USD())).unwrap();
1187 assert_eq!(balance.total, Money::from("-1000 USD"));
1188 assert_eq!(balance.locked, Money::from("500 USD"));
1189 assert_eq!(balance.free, Money::from("-1500 USD"));
1190 }
1191
1192 #[rstest]
1193 fn test_update_balances_rejects_negative_total_when_borrowing_disabled(
1194 mut cash_account: CashAccount,
1195 ) {
1196 let usd = Currency::USD();
1197 let balances_before = cash_account.balances();
1198
1199 let result = cash_account.update_balances(&[AccountBalance::new(
1200 Money::from("-500 USD"),
1201 Money::zero(usd),
1202 Money::from("-500 USD"),
1203 )]);
1204
1205 assert_eq!(
1206 result.unwrap_err().to_string(),
1207 "Cash account balance would become negative: -500.00 USD (borrowing not allowed for SIM-001)"
1208 );
1209 assert_eq!(cash_account.balances(), balances_before);
1210 }
1211
1212 #[rstest]
1213 fn test_update_balances_accepts_negative_total_when_borrowing_enabled(
1214 mut cash_account_borrowing: CashAccount,
1215 ) {
1216 let usd = Currency::USD();
1217 let balance = AccountBalance::new(
1218 Money::from("-500 USD"),
1219 Money::zero(usd),
1220 Money::from("-500 USD"),
1221 );
1222
1223 cash_account_borrowing.update_balances(&[balance]).unwrap();
1224
1225 assert_eq!(cash_account_borrowing.balance(Some(usd)), Some(&balance));
1226 }
1227
1228 #[rstest]
1229 fn test_apply_returns_error_when_negative_balance_and_borrowing_disabled() {
1230 let initial_event = AccountState::new(
1231 AccountId::from("SIM-001"),
1232 AccountType::Cash,
1233 vec![AccountBalance::new(
1234 Money::from("1000 USD"),
1235 Money::from("0 USD"),
1236 Money::from("1000 USD"),
1237 )],
1238 vec![],
1239 true,
1240 uuid4(),
1241 0.into(),
1242 0.into(),
1243 Some(Currency::USD()),
1244 );
1245
1246 let mut account = CashAccount::new(initial_event, false, false);
1247
1248 let negative_balance_event = AccountState::new(
1249 AccountId::from("SIM-001"),
1250 AccountType::Cash,
1251 vec![AccountBalance::new(
1252 Money::from("-500 USD"),
1253 Money::from("0 USD"),
1254 Money::from("-500 USD"),
1255 )],
1256 vec![],
1257 true,
1258 uuid4(),
1259 1.into(),
1260 1.into(),
1261 Some(Currency::USD()),
1262 );
1263
1264 let result = account.apply(negative_balance_event);
1265
1266 assert!(result.is_err());
1267 let err_msg = result.unwrap_err().to_string();
1268 assert!(err_msg.contains("negative"));
1269 assert!(err_msg.contains("borrowing not allowed"));
1270 }
1271
1272 #[rstest]
1273 fn test_apply_succeeds_when_negative_balance_and_borrowing_enabled() {
1274 let initial_event = AccountState::new(
1275 AccountId::from("SIM-001"),
1276 AccountType::Cash,
1277 vec![AccountBalance::new(
1278 Money::from("1000 USD"),
1279 Money::from("0 USD"),
1280 Money::from("1000 USD"),
1281 )],
1282 vec![],
1283 true,
1284 uuid4(),
1285 0.into(),
1286 0.into(),
1287 Some(Currency::USD()),
1288 );
1289
1290 let mut account = CashAccount::new(initial_event, false, true);
1291
1292 let negative_balance_event = AccountState::new(
1293 AccountId::from("SIM-001"),
1294 AccountType::Cash,
1295 vec![AccountBalance::new(
1296 Money::from("-500 USD"),
1297 Money::from("0 USD"),
1298 Money::from("-500 USD"),
1299 )],
1300 vec![],
1301 true,
1302 uuid4(),
1303 1.into(),
1304 1.into(),
1305 Some(Currency::USD()),
1306 );
1307
1308 let result = account.apply(negative_balance_event);
1309
1310 assert!(result.is_ok());
1311 assert_eq!(
1312 account.balance_total(Some(Currency::USD())),
1313 Some(Money::from("-500 USD"))
1314 );
1315 }
1316
1317 #[rstest]
1318 fn test_apply_clears_per_instrument_locks() {
1319 let initial_event = AccountState::new(
1320 AccountId::from("SIM-001"),
1321 AccountType::Cash,
1322 vec![AccountBalance::new(
1323 Money::from("10000 USD"),
1324 Money::from("0 USD"),
1325 Money::from("10000 USD"),
1326 )],
1327 vec![],
1328 true,
1329 uuid4(),
1330 0.into(),
1331 0.into(),
1332 Some(Currency::USD()),
1333 );
1334
1335 let mut account = CashAccount::new(initial_event, false, false);
1336 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1337
1338 account
1340 .update_balance_locked(instrument_id, Money::from("5000 USD"))
1341 .unwrap();
1342 assert_eq!(account.balances_locked.len(), 1);
1343
1344 let new_event = AccountState::new(
1346 AccountId::from("SIM-001"),
1347 AccountType::Cash,
1348 vec![AccountBalance::new(
1349 Money::from("8000 USD"),
1350 Money::from("0 USD"),
1351 Money::from("8000 USD"),
1352 )],
1353 vec![],
1354 true,
1355 uuid4(),
1356 1.into(),
1357 1.into(),
1358 Some(Currency::USD()),
1359 );
1360
1361 account.apply(new_event).unwrap();
1362
1363 assert!(account.balances_locked.is_empty());
1364 assert_eq!(
1365 account.balance_total(Some(Currency::USD())),
1366 Some(Money::from("8000 USD"))
1367 );
1368 }
1369
1370 #[rstest]
1371 fn test_apply_empty_balances_preserves_per_instrument_locks() {
1372 let initial_event = AccountState::new(
1373 AccountId::from("SIM-001"),
1374 AccountType::Cash,
1375 vec![AccountBalance::new(
1376 Money::from("10000 USD"),
1377 Money::from("0 USD"),
1378 Money::from("10000 USD"),
1379 )],
1380 vec![],
1381 true,
1382 uuid4(),
1383 0.into(),
1384 0.into(),
1385 Some(Currency::USD()),
1386 );
1387
1388 let mut account = CashAccount::new(initial_event, false, false);
1389 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1390 account
1391 .update_balance_locked(instrument_id, Money::from("5000 USD"))
1392 .unwrap();
1393 assert_eq!(account.balances_locked.len(), 1);
1394
1395 let empty_event = AccountState::new(
1396 AccountId::from("SIM-001"),
1397 AccountType::Cash,
1398 vec![],
1399 vec![],
1400 true,
1401 uuid4(),
1402 1.into(),
1403 1.into(),
1404 Some(Currency::USD()),
1405 );
1406
1407 account.apply(empty_event).unwrap();
1408
1409 assert_eq!(account.balances_locked.len(), 1);
1410 assert_eq!(
1411 account.balance_total(Some(Currency::USD())),
1412 Some(Money::from("10000 USD"))
1413 );
1414 assert_eq!(account.event_count(), 2);
1415 }
1416}