1use ahash::AHashMap;
22use indexmap::IndexMap;
23use nautilus_core::{
24 DurationNanos, UnixNanos,
25 correctness::{CorrectnessError, CorrectnessResult, FAILED, check_equal},
26};
27use serde::{Deserialize, Serialize};
28
29use crate::{
30 accounts::fill_position_pnl,
31 enums::{AccountType, LiquiditySide, OrderSide},
32 events::{AccountState, OrderFilled},
33 fees::{MakerTakerFeeRates, calculate_maker_taker_commission},
34 identifiers::{AccountId, InstrumentId},
35 instruments::{Instrument, InstrumentAny},
36 position::{Position, amount, money_from_qty, notional},
37 types::{AccountBalance, Currency, Money, Price, Quantity},
38};
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
45)]
46pub struct BaseAccount {
47 pub id: AccountId,
49 pub account_type: AccountType,
51 pub base_currency: Option<Currency>,
53 pub calculate_account_state: bool,
56 pub events: Vec<AccountState>,
58 pub commissions: AHashMap<Currency, Money>,
60 pub balances: IndexMap<Currency, AccountBalance>,
62 pub balances_starting: IndexMap<Currency, Money>,
64}
65
66impl BaseAccount {
67 #[must_use]
69 pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
70 let mut balances_starting: IndexMap<Currency, Money> = IndexMap::new();
71 let mut balances: IndexMap<Currency, AccountBalance> = IndexMap::new();
72 event.balances.iter().for_each(|balance| {
73 balances_starting.insert(balance.currency, balance.total);
74 balances.insert(balance.currency, *balance);
75 });
76 Self {
77 id: event.account_id,
78 account_type: event.account_type,
79 base_currency: event.base_currency,
80 calculate_account_state,
81 events: vec![event],
82 commissions: AHashMap::new(),
83 balances,
84 balances_starting,
85 }
86 }
87
88 #[must_use]
89 pub(crate) fn clone_without_events(&self) -> Self {
90 Self {
91 id: self.id,
92 account_type: self.account_type,
93 base_currency: self.base_currency,
94 calculate_account_state: self.calculate_account_state,
95 events: Vec::new(),
96 commissions: self.commissions.clone(),
97 balances: self.balances.clone(),
98 balances_starting: self.balances_starting.clone(),
99 }
100 }
101
102 #[must_use]
108 pub fn base_balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
109 let currency = currency
110 .or(self.base_currency)
111 .expect("Currency must be specified");
112 self.balances.get(¤cy)
113 }
114
115 #[must_use]
121 pub fn base_balance_total(&self, currency: Option<Currency>) -> Option<Money> {
122 self.base_balance(currency).map(|balance| balance.total)
123 }
124
125 #[must_use]
126 pub fn base_balances_total(&self) -> IndexMap<Currency, Money> {
127 self.balances
128 .iter()
129 .map(|(currency, balance)| (*currency, balance.total))
130 .collect()
131 }
132
133 #[must_use]
139 pub fn base_balance_free(&self, currency: Option<Currency>) -> Option<Money> {
140 self.base_balance(currency).map(|balance| balance.free)
141 }
142
143 #[must_use]
144 pub fn base_balances_free(&self) -> IndexMap<Currency, Money> {
145 self.balances
146 .iter()
147 .map(|(currency, balance)| (*currency, balance.free))
148 .collect()
149 }
150
151 #[must_use]
157 pub fn base_balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
158 self.base_balance(currency).map(|balance| balance.locked)
159 }
160
161 #[must_use]
162 pub fn base_balances_locked(&self) -> IndexMap<Currency, Money> {
163 self.balances
164 .iter()
165 .map(|(currency, balance)| (*currency, balance.locked))
166 .collect()
167 }
168
169 #[must_use]
170 pub fn base_last_event(&self) -> Option<AccountState> {
171 self.events.last().cloned()
172 }
173
174 pub fn update_balances(&mut self, balances: &[AccountBalance]) {
181 for balance in balances {
182 self.balances.insert(balance.currency, *balance);
183 }
184 }
185
186 pub fn update_commissions(&mut self, commission: Money) {
193 self.try_update_commissions(commission)
194 .expect("commission total exceeded Money bounds");
195 }
196
197 pub fn try_update_commissions(&mut self, commission: Money) -> anyhow::Result<()> {
203 if commission.is_zero() {
204 return Ok(());
205 }
206 let currency = commission.currency;
207 let total = self
208 .commissions
209 .get(¤cy)
210 .copied()
211 .map_or(Some(commission), |total| total.checked_add(commission))
212 .ok_or_else(|| anyhow::anyhow!("{currency} commission total exceeds Money bounds"))?;
213 self.commissions.insert(currency, total);
214 Ok(())
215 }
216
217 #[must_use]
219 pub fn commission(&self, currency: &Currency) -> Option<Money> {
220 self.commissions.get(currency).copied()
221 }
222
223 #[must_use]
225 pub fn commissions(&self) -> AHashMap<Currency, Money> {
226 self.commissions.clone()
227 }
228
229 pub(crate) fn check_event_account_id(&self, event: &AccountState) -> anyhow::Result<()> {
238 anyhow::ensure!(
239 event.account_id == self.id,
240 "Account event had a different account ID: expected {}, received {}",
241 self.id,
242 event.account_id
243 );
244 Ok(())
245 }
246
247 pub fn base_apply(&mut self, event: AccountState) {
254 check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
255 self.update_balances(&event.balances);
256 self.events.push(event);
257 }
258
259 pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
267 let Ok(lookback_ns) = DurationNanos::try_from_secs(lookback_secs) else {
268 log::warn!(
269 "Cannot purge account events: lookback_secs {lookback_secs} is not representable in `u64` nanoseconds"
270 );
271 return;
272 };
273 let purge_cutoff = ts_now.checked_sub(lookback_ns);
274
275 let mut retained_events = Vec::new();
276
277 for event in &self.events {
278 if purge_cutoff.is_none_or(|cutoff| event.ts_event > cutoff) {
279 retained_events.push(event.clone());
280 }
281 }
282
283 if retained_events.is_empty() && !self.events.is_empty() {
285 retained_events.push(self.events.last().expect("events not empty").clone());
286 }
287
288 self.events = retained_events;
289 }
290
291 pub fn base_calculate_balance_locked(
299 &self,
300 instrument: &InstrumentAny,
301 side: OrderSide,
302 quantity: Quantity,
303 price: Price,
304 use_quote_for_inverse: Option<bool>,
305 ) -> anyhow::Result<Money> {
306 match side {
307 OrderSide::Buy => {
311 let notional =
312 calculate_notional(instrument, quantity, price, use_quote_for_inverse)?;
313 Ok(notional.max(Money::zero(notional.currency)))
314 }
315 OrderSide::Sell => match instrument.base_currency() {
317 Some(currency) => Ok(money_from_qty(quantity, currency)?),
318 None => Ok(Money::from_raw_checked(0, instrument.quote_currency())?),
319 },
320 }
321 }
322
323 #[allow(
338 clippy::needless_pass_by_value,
339 reason = "matches the owned Position input of Account::calculate_pnls"
340 )]
341 pub fn base_calculate_pnls(
342 &self,
343 instrument: &InstrumentAny,
344 fill: &OrderFilled,
345 position: Option<Position>,
346 ) -> anyhow::Result<Vec<Money>> {
347 if let Some(commission) = fill.commission {
348 amount(commission)?;
349 }
350
351 fill_position_pnl(instrument, fill, position.as_ref())?;
352
353 let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
354 let base_currency = instrument.base_currency();
355
356 let fill_qty = fill.last_qty;
358 let notional = calculate_notional(instrument, fill_qty, fill.last_px, None)?;
359
360 if fill.order_side == OrderSide::Buy {
361 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
362 pnls.insert(
363 base_currency_value,
364 money_from_qty(fill_qty, base_currency_value)?,
365 );
366 }
367 pnls.insert(notional.currency, -notional);
368 } else {
369 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
370 pnls.insert(
371 base_currency_value,
372 -money_from_qty(fill_qty, base_currency_value)?,
373 );
374 }
375 pnls.insert(notional.currency, notional);
376 }
377 Ok(pnls.into_values().collect())
378 }
379
380 pub fn base_calculate_commission(
390 &self,
391 instrument: &InstrumentAny,
392 last_qty: Quantity,
393 last_px: Price,
394 liquidity_side: LiquiditySide,
395 fee_rates: MakerTakerFeeRates,
396 use_quote_for_inverse: Option<bool>,
397 ) -> anyhow::Result<Money> {
398 let rate = fee_rates.rate_for(liquidity_side)?;
399 calculate_maker_taker_commission(instrument, last_qty, last_px, rate, use_quote_for_inverse)
400 }
401}
402
403fn calculate_notional(
404 instrument: &InstrumentAny,
405 quantity: Quantity,
406 price: Price,
407 use_quote_for_inverse: Option<bool>,
408) -> anyhow::Result<Money> {
409 if instrument.is_inverse() && use_quote_for_inverse.unwrap_or(false) {
410 return Ok(money_from_qty(quantity, instrument.quote_currency())?);
411 }
412
413 let currency = if instrument.is_inverse() {
414 instrument
415 .base_currency()
416 .ok_or_else(|| anyhow::anyhow!("inverse instrument has no base currency"))?
417 } else {
418 instrument.cost_currency()
419 };
420
421 Ok(notional(
422 quantity,
423 price,
424 instrument.multiplier(),
425 instrument
426 .instrument_class()
427 .divides_notional_by_price(instrument.is_inverse()),
428 currency,
429 )?)
430}
431
432pub(crate) fn update_balance_locked(
444 balances: &mut IndexMap<Currency, AccountBalance>,
445 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
446 instrument_id: InstrumentId,
447 locked: Money,
448) -> anyhow::Result<()> {
449 anyhow::ensure!(
450 !locked.is_negative(),
451 "locked balance was negative: {locked}"
452 );
453
454 let currency = locked.currency;
455 let key = (instrument_id, currency);
456
457 let Some(current_balance) = balances.get(¤cy).copied() else {
458 balances_locked.insert(key, locked);
459 return Ok(());
460 };
461
462 anyhow::ensure!(
463 current_balance.currency.precision == currency.precision,
464 "Cannot update {currency} reservation: precision {} differed from balance precision {}",
465 currency.precision,
466 current_balance.currency.precision
467 );
468
469 let previous = balances_locked.insert(key, locked);
470
471 match balance_from_locks(current_balance, balances_locked) {
472 Ok(balance) => {
473 balances.insert(currency, balance);
474 Ok(())
475 }
476 Err(e) => {
477 match previous {
479 Some(previous) => balances_locked.insert(key, previous),
480 None => balances_locked.remove(&key),
481 };
482 Err(e.into())
483 }
484 }
485}
486
487pub(crate) fn clear_balance_locked(
489 balances: &mut IndexMap<Currency, AccountBalance>,
490 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
491 instrument_id: InstrumentId,
492) {
493 let currencies_to_recalc: Vec<Currency> = balances_locked
494 .keys()
495 .filter(|(id, _)| *id == instrument_id)
496 .map(|(_, currency)| *currency)
497 .collect();
498
499 for currency in ¤cies_to_recalc {
500 balances_locked.remove(&(instrument_id, *currency));
501 }
502
503 for currency in currencies_to_recalc {
504 recalculate_balance(balances, balances_locked, currency);
505 }
506}
507
508pub(crate) fn recalculate_balance(
513 balances: &mut IndexMap<Currency, AccountBalance>,
514 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
515 currency: Currency,
516) {
517 let current_balance = if let Some(balance) = balances.get(¤cy) {
518 *balance
519 } else {
520 log::debug!("Cannot recalculate balance when no current balance for {currency}");
521 return;
522 };
523
524 let new_balance = match balance_from_locks(current_balance, balances_locked) {
525 Ok(balance) => balance,
526 Err(e) => {
527 log::error!(
528 "Cannot recalculate {currency} balance from reservations: {e}; using a non-spendable balance"
529 );
530 non_spendable_balance(current_balance)
531 }
532 };
533
534 balances.insert(currency, new_balance);
535}
536
537pub(crate) fn balance_from_locks(
544 current_balance: AccountBalance,
545 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
546) -> CorrectnessResult<AccountBalance> {
547 let currency = current_balance.currency;
548 let mut locked_total = Money::zero(currency);
549
550 for locked in balances_locked
551 .values()
552 .filter(|locked| locked.currency == currency)
553 {
554 if locked.is_negative() {
555 return Err(CorrectnessError::PredicateViolation {
556 message: format!("locked balance was negative: {locked}"),
557 });
558 }
559
560 if locked.currency.precision != currency.precision {
561 return Err(CorrectnessError::PredicateViolation {
562 message: format!(
563 "locked balance precision {} differed from balance precision {} for {currency}",
564 locked.currency.precision, currency.precision
565 ),
566 });
567 }
568
569 let reservation = if current_balance.total.is_negative() {
570 *locked
571 } else {
572 (*locked).min(current_balance.total - locked_total)
573 };
574
575 locked_total = locked_total.checked_add(reservation).ok_or_else(|| {
576 CorrectnessError::PredicateViolation {
577 message: format!("derived locked balance exceeded Money bounds for {currency}"),
578 }
579 })?;
580 }
581
582 let free = current_balance
583 .total
584 .checked_sub(locked_total)
585 .ok_or_else(|| CorrectnessError::PredicateViolation {
586 message: format!(
587 "derived free balance exceeded Money bounds for total {} and locked {locked_total}",
588 current_balance.total
589 ),
590 })?;
591
592 AccountBalance::new_checked(current_balance.total, locked_total, free)
593}
594
595fn non_spendable_balance(current_balance: AccountBalance) -> AccountBalance {
596 let zero = Money::zero(current_balance.currency);
597
598 let (locked, free) = if current_balance.total.is_negative() {
599 (zero, current_balance.total)
600 } else {
601 (current_balance.total, zero)
602 };
603
604 AccountBalance {
605 currency: current_balance.currency,
606 total: current_balance.total,
607 locked,
608 free,
609 }
610}
611
612#[cfg(all(test, feature = "test-support"))]
613mod tests {
614 use rstest::rstest;
615 use rust_decimal::Decimal;
616 use rust_decimal_macros::dec;
617
618 use super::*;
619 use crate::{events::account::stubs::cash_account_state, types::money::MONEY_RAW_MAX};
620
621 #[rstest]
622 fn test_base_purge_account_events_retains_latest_when_all_purged() {
623 use crate::{
624 enums::AccountType,
625 events::account::stubs::cash_account_state,
626 identifiers::stubs::{account_id, uuid4},
627 types::{Currency, stubs::stub_account_balance},
628 };
629
630 let mut account = BaseAccount::new(cash_account_state(), true);
631
632 let event1 = AccountState::new(
634 account_id(),
635 AccountType::Cash,
636 vec![stub_account_balance()],
637 vec![],
638 true,
639 uuid4(),
640 UnixNanos::from(100_000_000),
641 UnixNanos::from(100_000_000),
642 Some(Currency::USD()),
643 );
644 let event2 = AccountState::new(
645 account_id(),
646 AccountType::Cash,
647 vec![stub_account_balance()],
648 vec![],
649 true,
650 uuid4(),
651 UnixNanos::from(200_000_000),
652 UnixNanos::from(200_000_000),
653 Some(Currency::USD()),
654 );
655 let event3 = AccountState::new(
656 account_id(),
657 AccountType::Cash,
658 vec![stub_account_balance()],
659 vec![],
660 true,
661 uuid4(),
662 UnixNanos::from(300_000_000),
663 UnixNanos::from(300_000_000),
664 Some(Currency::USD()),
665 );
666
667 account.base_apply(event1);
668 account.base_apply(event2);
669 account.base_apply(event3.clone());
670
671 assert_eq!(account.events.len(), 4);
672
673 account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
674
675 assert_eq!(account.events.len(), 1);
676 assert_eq!(account.events[0].ts_event, event3.ts_event);
677 assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
678 }
679
680 #[rstest]
681 fn test_base_purge_account_events_retains_all_for_overflowing_lookback() {
682 let mut account = BaseAccount::new(cash_account_state(), true);
683 let mut event = cash_account_state();
684 event.ts_event = UnixNanos::from(1);
685 account.base_apply(event);
686
687 account.base_purge_account_events(UnixNanos::from(u64::MAX), u64::MAX);
688
689 assert_eq!(account.events.len(), 2);
690 }
691
692 #[rstest]
693 fn test_base_purge_account_events_drops_event_exactly_at_cutoff() {
694 let mut account = BaseAccount::new(cash_account_state(), true);
695 let mut at_cutoff = cash_account_state();
696 at_cutoff.ts_event = UnixNanos::from(200_000_000_000);
697 account.base_apply(at_cutoff);
698 let mut after_cutoff = cash_account_state();
699 after_cutoff.ts_event = UnixNanos::from(200_000_000_001);
700 account.base_apply(after_cutoff);
701
702 account.base_purge_account_events(UnixNanos::from(300_000_000_000), 100);
703
704 assert_eq!(account.events.len(), 1);
705 assert_eq!(account.events[0].ts_event, UnixNanos::from(200_000_000_001));
706 }
707
708 #[rstest]
709 fn test_base_purge_account_events_retains_future_event_without_overflow() {
710 let mut event = cash_account_state();
711 event.ts_event = UnixNanos::from(u64::MAX - 1);
712 let mut account = BaseAccount::new(event, true);
713
714 account.base_purge_account_events(UnixNanos::from(u64::MAX), 60);
715
716 assert_eq!(account.events.len(), 1);
717 }
718
719 #[rstest]
720 #[should_panic(
721 expected = r#"lhs_param: "event.account_id", rhs_param: "self.id", lhs: "OTHER-001", rhs: "SIM-001""#
722 )]
723 fn test_base_apply_panics_on_different_account_id() {
724 let mut account = BaseAccount::new(cash_account_state(), true);
725 let mut event = cash_account_state();
726 event.account_id = AccountId::from("OTHER-001");
727
728 account.base_apply(event);
729 }
730
731 fn usd_balances(total: &str) -> IndexMap<Currency, AccountBalance> {
732 let total = Money::from(total);
733 let mut balances = IndexMap::new();
734 balances.insert(
735 Currency::USD(),
736 AccountBalance::new(total, Money::zero(Currency::USD()), total),
737 );
738 balances
739 }
740
741 fn mismatched_usd() -> Currency {
742 Currency::new(
743 "USD",
744 Currency::USD().precision + 1,
745 840,
746 "United States dollar",
747 crate::enums::CurrencyType::Fiat,
748 )
749 }
750
751 #[rstest]
754 #[case::observed_currency(true)]
755 #[case::unobserved_currency(false)]
756 fn test_update_balance_locked_rejects_negative_without_mutation(#[case] observed: bool) {
757 let mut balances = if observed {
758 usd_balances("1000 USD")
759 } else {
760 IndexMap::new()
761 };
762 let balances_before = balances.clone();
763 let mut balances_locked = AHashMap::new();
764 let instrument_id = InstrumentId::from("AUD/USD.SIM");
765
766 let error = update_balance_locked(
767 &mut balances,
768 &mut balances_locked,
769 instrument_id,
770 Money::from("-1 USD"),
771 )
772 .unwrap_err();
773
774 assert_eq!(error.to_string(), "locked balance was negative: -1.00 USD");
775 assert!(balances_locked.is_empty());
776 assert_eq!(balances, balances_before);
777 }
778
779 #[rstest]
780 fn test_update_balance_locked_restores_prior_reservation_on_failure() {
781 let usd = Currency::USD();
782 let mut balances = usd_balances("1000 USD");
783 let stale_key = (InstrumentId::from("EUR/USD.SIM"), mismatched_usd());
784 let stale = Money::from_decimal(Decimal::from(10), mismatched_usd()).unwrap();
785 let mut balances_locked = AHashMap::from([(stale_key, stale)]);
786 let instrument_id = InstrumentId::from("AUD/USD.SIM");
787
788 let result = update_balance_locked(
791 &mut balances,
792 &mut balances_locked,
793 instrument_id,
794 Money::from("100 USD"),
795 );
796
797 assert!(result.is_err());
798 assert_eq!(balances_locked, AHashMap::from([(stale_key, stale)]));
799 assert_eq!(balances, usd_balances("1000 USD"));
800 assert_eq!(balances[&usd].free, Money::from("1000 USD"));
801 }
802
803 #[rstest]
804 fn test_balance_from_locks_clamps_reservations_to_total() {
805 let usd = Currency::USD();
806 let total = Money::from("100 USD");
807 let current = AccountBalance::new(total, Money::zero(usd), total);
808 let mut balances_locked = AHashMap::new();
809 balances_locked.insert(
810 (InstrumentId::from("AUD/USD.SIM"), usd),
811 Money::from("60 USD"),
812 );
813 balances_locked.insert(
814 (InstrumentId::from("EUR/USD.SIM"), usd),
815 Money::from("60 USD"),
816 );
817
818 let balance = balance_from_locks(current, &balances_locked).unwrap();
819
820 assert_eq!(balance.total, total);
821 assert_eq!(balance.locked, total);
822 assert_eq!(balance.free, Money::zero(usd));
823 }
824
825 #[rstest]
826 #[case::positive_total("1000 USD", "1000 USD", "0 USD")]
827 #[case::negative_total("-1000 USD", "0 USD", "-1000 USD")]
828 fn test_recalculate_balance_degrades_to_non_spendable_for_invalid_reservation(
829 #[case] total: &str,
830 #[case] expected_locked: &str,
831 #[case] expected_free: &str,
832 ) {
833 use crate::{enums::CurrencyType, types::Currency};
834
835 let usd = Currency::USD();
836 let total = Money::from(total);
837 let mut balances = IndexMap::new();
838 balances.insert(usd, AccountBalance::new(total, Money::zero(usd), total));
839 let mismatched_usd = Currency::new(
841 "USD",
842 usd.precision + 1,
843 840,
844 "United States dollar",
845 CurrencyType::Fiat,
846 );
847 let mut balances_locked = AHashMap::new();
848 balances_locked.insert(
849 (InstrumentId::from("AUD/USD.SIM"), mismatched_usd),
850 Money::from_decimal(Decimal::from(100), mismatched_usd).unwrap(),
851 );
852
853 recalculate_balance(&mut balances, &balances_locked, usd);
854
855 let balance = balances.get(&usd).expect("balance should be retained");
856 assert_eq!(balance.total, total);
857 assert_eq!(balance.locked, Money::from(expected_locked));
858 assert_eq!(balance.free, Money::from(expected_free));
859 }
860
861 #[rstest]
862 fn test_update_commissions_native_precision_preserved() {
863 use crate::{
864 events::account::stubs::cash_account_state,
865 types::{Currency, Money},
866 };
867
868 let mut account = BaseAccount::new(cash_account_state(), true);
869 let usd = Currency::USD();
870
871 account.update_commissions(Money::from_raw(1, usd));
872 account.update_commissions(Money::from_raw(2, usd));
873
874 assert_eq!(account.commission(&usd), Some(Money::from_raw(3, usd)));
875 }
876
877 #[rstest]
878 fn test_update_commissions_exact_usd_accumulates_with_legacy_money() {
879 let mut account = BaseAccount::new(cash_account_state(), true);
880 let usd = Currency::USD();
881 let exact = Money::from_decimal_exact(dec!(0.000001), usd).unwrap();
882 account.update_commissions(exact);
883 account.update_commissions(Money::from("0.01 USD"));
884
885 assert_eq!(
886 account.commission(&usd).unwrap().as_decimal(),
887 dec!(0.010001)
888 );
889 }
890
891 #[rstest]
892 fn test_commissions_returns_every_currency() {
893 let mut account = BaseAccount::new(cash_account_state(), true);
894 account.update_commissions(Money::from("2.50 USD"));
895 account.update_commissions(Money::from("1.25 AUD"));
896
897 let commissions = account.commissions();
898
899 assert_eq!(commissions.len(), 2);
900 assert_eq!(
901 commissions.get(&Currency::USD()),
902 Some(&Money::from("2.50 USD"))
903 );
904 assert_eq!(
905 commissions.get(&Currency::AUD()),
906 Some(&Money::from("1.25 AUD"))
907 );
908 }
909
910 #[rstest]
911 fn test_try_update_commissions_overflow_preserves_total() {
912 let mut account = BaseAccount::new(cash_account_state(), true);
913 let usd = Currency::USD();
914 let maximum = Money::from_raw(MONEY_RAW_MAX, usd);
915
916 account.try_update_commissions(maximum).unwrap();
917 let result = account.try_update_commissions(Money::from("0.01 USD"));
918
919 assert!(result.is_err());
920 assert_eq!(account.commission(&usd), Some(maximum));
921 }
922}