1use ahash::AHashMap;
22use indexmap::IndexMap;
23use nautilus_core::{
24 DurationNanos, UnixNanos,
25 correctness::{CorrectnessError, CorrectnessResult, FAILED, check_equal},
26};
27use rust_decimal::Decimal;
28use serde::{Deserialize, Serialize};
29
30use crate::{
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,
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 let commission = commission.normalized();
205 if commission.is_zero() {
206 return Ok(());
207 }
208 let currency = commission.currency;
209 let total = self
210 .commissions
211 .get(¤cy)
212 .copied()
213 .map_or(Some(commission), |total| total.checked_add(commission))
214 .ok_or_else(|| anyhow::anyhow!("{currency} commission total exceeds Money bounds"))?;
215 self.commissions.insert(currency, total);
216 Ok(())
217 }
218
219 #[must_use]
221 pub fn commission(&self, currency: &Currency) -> Option<Money> {
222 self.commissions.get(currency).copied()
223 }
224
225 #[must_use]
227 pub fn commissions(&self) -> AHashMap<Currency, Money> {
228 self.commissions.clone()
229 }
230
231 pub(crate) fn check_event_account_id(&self, event: &AccountState) -> anyhow::Result<()> {
240 anyhow::ensure!(
241 event.account_id == self.id,
242 "Account event had a different account ID: expected {}, received {}",
243 self.id,
244 event.account_id
245 );
246 Ok(())
247 }
248
249 pub fn base_apply(&mut self, event: AccountState) {
256 check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
257 self.update_balances(&event.balances);
258 self.events.push(event);
259 }
260
261 pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
269 let Ok(lookback_ns) = DurationNanos::try_from_secs(lookback_secs) else {
270 log::warn!(
271 "Cannot purge account events: lookback_secs {lookback_secs} is not representable in `u64` nanoseconds"
272 );
273 return;
274 };
275 let purge_cutoff = ts_now.checked_sub(lookback_ns);
276
277 let mut retained_events = Vec::new();
278
279 for event in &self.events {
280 if purge_cutoff.is_none_or(|cutoff| event.ts_event > cutoff) {
281 retained_events.push(event.clone());
282 }
283 }
284
285 if retained_events.is_empty() && !self.events.is_empty() {
287 retained_events.push(self.events.last().expect("events not empty").clone());
288 }
289
290 self.events = retained_events;
291 }
292
293 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 let (amount, currency) = match side {
307 OrderSide::Buy => {
311 let notional = instrument.try_calculate_notional_value(
312 quantity,
313 price,
314 use_quote_for_inverse,
315 )?;
316 (notional.as_decimal().max(Decimal::ZERO), notional.currency)
317 }
318 OrderSide::Sell => match instrument.base_currency() {
320 Some(base_currency) => (quantity.as_decimal(), base_currency),
321 None => (Decimal::ZERO, instrument.quote_currency()),
322 },
323 };
324
325 Ok(Money::from_decimal(amount, currency)?)
326 }
327
328 pub fn base_calculate_pnls(
341 &self,
342 instrument: &InstrumentAny,
343 fill: &OrderFilled,
344 _position: Option<Position>,
345 ) -> anyhow::Result<Vec<Money>> {
346 let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
347 let base_currency = instrument.base_currency();
348
349 let fill_qty = fill.last_qty;
351 let notional = instrument.try_calculate_notional_value(fill_qty, fill.last_px, None)?;
352
353 if fill.order_side == OrderSide::Buy {
354 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
355 pnls.insert(
356 base_currency_value,
357 Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
358 );
359 }
360 pnls.insert(notional.currency, -notional);
361 } else {
362 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
363 pnls.insert(
364 base_currency_value,
365 -Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
366 );
367 }
368 pnls.insert(notional.currency, notional);
369 }
370 Ok(pnls.into_values().collect())
371 }
372
373 pub fn base_calculate_commission(
383 &self,
384 instrument: &InstrumentAny,
385 last_qty: Quantity,
386 last_px: Price,
387 liquidity_side: LiquiditySide,
388 fee_rates: MakerTakerFeeRates,
389 use_quote_for_inverse: Option<bool>,
390 ) -> anyhow::Result<Money> {
391 let rate = fee_rates.rate_for(liquidity_side)?;
392 calculate_maker_taker_commission(instrument, last_qty, last_px, rate, use_quote_for_inverse)
393 }
394}
395
396pub(crate) fn update_balance_locked(
408 balances: &mut IndexMap<Currency, AccountBalance>,
409 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
410 instrument_id: InstrumentId,
411 locked: Money,
412) -> anyhow::Result<()> {
413 anyhow::ensure!(
414 !locked.is_negative(),
415 "locked balance was negative: {locked}"
416 );
417
418 let currency = locked.currency;
419 let key = (instrument_id, currency);
420
421 let Some(current_balance) = balances.get(¤cy).copied() else {
422 balances_locked.insert(key, locked);
423 return Ok(());
424 };
425
426 anyhow::ensure!(
427 current_balance.currency.precision == currency.precision,
428 "Cannot update {currency} reservation: precision {} differed from balance precision {}",
429 currency.precision,
430 current_balance.currency.precision
431 );
432
433 let previous = balances_locked.insert(key, locked);
434
435 match balance_from_locks(current_balance, balances_locked) {
436 Ok(balance) => {
437 balances.insert(currency, balance);
438 Ok(())
439 }
440 Err(e) => {
441 match previous {
443 Some(previous) => balances_locked.insert(key, previous),
444 None => balances_locked.remove(&key),
445 };
446 Err(e.into())
447 }
448 }
449}
450
451pub(crate) fn clear_balance_locked(
453 balances: &mut IndexMap<Currency, AccountBalance>,
454 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
455 instrument_id: InstrumentId,
456) {
457 let currencies_to_recalc: Vec<Currency> = balances_locked
458 .keys()
459 .filter(|(id, _)| *id == instrument_id)
460 .map(|(_, currency)| *currency)
461 .collect();
462
463 for currency in ¤cies_to_recalc {
464 balances_locked.remove(&(instrument_id, *currency));
465 }
466
467 for currency in currencies_to_recalc {
468 recalculate_balance(balances, balances_locked, currency);
469 }
470}
471
472pub(crate) fn recalculate_balance(
477 balances: &mut IndexMap<Currency, AccountBalance>,
478 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
479 currency: Currency,
480) {
481 let current_balance = if let Some(balance) = balances.get(¤cy) {
482 *balance
483 } else {
484 log::debug!("Cannot recalculate balance when no current balance for {currency}");
485 return;
486 };
487
488 let new_balance = match balance_from_locks(current_balance, balances_locked) {
489 Ok(balance) => balance,
490 Err(e) => {
491 log::error!(
492 "Cannot recalculate {currency} balance from reservations: {e}; using a non-spendable balance"
493 );
494 non_spendable_balance(current_balance)
495 }
496 };
497
498 balances.insert(currency, new_balance);
499}
500
501pub(crate) fn balance_from_locks(
508 current_balance: AccountBalance,
509 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
510) -> CorrectnessResult<AccountBalance> {
511 let currency = current_balance.currency;
512 let mut locked_total = Money::zero(currency);
513
514 for locked in balances_locked
515 .values()
516 .filter(|locked| locked.currency == currency)
517 {
518 if locked.is_negative() {
519 return Err(CorrectnessError::PredicateViolation {
520 message: format!("locked balance was negative: {locked}"),
521 });
522 }
523
524 if locked.currency.precision != currency.precision {
525 return Err(CorrectnessError::PredicateViolation {
526 message: format!(
527 "locked balance precision {} differed from balance precision {} for {currency}",
528 locked.currency.precision, currency.precision
529 ),
530 });
531 }
532
533 let reservation = if current_balance.total.is_negative() {
534 *locked
535 } else {
536 (*locked).min(current_balance.total - locked_total)
537 };
538
539 locked_total = locked_total.checked_add(reservation).ok_or_else(|| {
540 CorrectnessError::PredicateViolation {
541 message: format!("derived locked balance exceeded Money bounds for {currency}"),
542 }
543 })?;
544 }
545
546 let free = current_balance
547 .total
548 .checked_sub(locked_total)
549 .ok_or_else(|| CorrectnessError::PredicateViolation {
550 message: format!(
551 "derived free balance exceeded Money bounds for total {} and locked {locked_total}",
552 current_balance.total
553 ),
554 })?;
555
556 AccountBalance::new_checked(current_balance.total, locked_total, free)
557}
558
559fn non_spendable_balance(current_balance: AccountBalance) -> AccountBalance {
560 let zero = Money::zero(current_balance.currency);
561
562 let (locked, free) = if current_balance.total.is_negative() {
563 (zero, current_balance.total)
564 } else {
565 (current_balance.total, zero)
566 };
567
568 AccountBalance {
569 currency: current_balance.currency,
570 total: current_balance.total,
571 locked,
572 free,
573 }
574}
575
576#[cfg(all(test, feature = "test-support"))]
577mod tests {
578 use rstest::rstest;
579
580 use super::*;
581 use crate::{events::account::stubs::cash_account_state, types::money::MONEY_RAW_MAX};
582
583 #[rstest]
584 fn test_base_purge_account_events_retains_latest_when_all_purged() {
585 use crate::{
586 enums::AccountType,
587 events::account::stubs::cash_account_state,
588 identifiers::stubs::{account_id, uuid4},
589 types::{Currency, stubs::stub_account_balance},
590 };
591
592 let mut account = BaseAccount::new(cash_account_state(), true);
593
594 let event1 = AccountState::new(
596 account_id(),
597 AccountType::Cash,
598 vec![stub_account_balance()],
599 vec![],
600 true,
601 uuid4(),
602 UnixNanos::from(100_000_000),
603 UnixNanos::from(100_000_000),
604 Some(Currency::USD()),
605 );
606 let event2 = AccountState::new(
607 account_id(),
608 AccountType::Cash,
609 vec![stub_account_balance()],
610 vec![],
611 true,
612 uuid4(),
613 UnixNanos::from(200_000_000),
614 UnixNanos::from(200_000_000),
615 Some(Currency::USD()),
616 );
617 let event3 = AccountState::new(
618 account_id(),
619 AccountType::Cash,
620 vec![stub_account_balance()],
621 vec![],
622 true,
623 uuid4(),
624 UnixNanos::from(300_000_000),
625 UnixNanos::from(300_000_000),
626 Some(Currency::USD()),
627 );
628
629 account.base_apply(event1);
630 account.base_apply(event2);
631 account.base_apply(event3.clone());
632
633 assert_eq!(account.events.len(), 4);
634
635 account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
636
637 assert_eq!(account.events.len(), 1);
638 assert_eq!(account.events[0].ts_event, event3.ts_event);
639 assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
640 }
641
642 #[rstest]
643 fn test_base_purge_account_events_retains_all_for_overflowing_lookback() {
644 let mut account = BaseAccount::new(cash_account_state(), true);
645 let mut event = cash_account_state();
646 event.ts_event = UnixNanos::from(1);
647 account.base_apply(event);
648
649 account.base_purge_account_events(UnixNanos::from(u64::MAX), u64::MAX);
650
651 assert_eq!(account.events.len(), 2);
652 }
653
654 #[rstest]
655 fn test_base_purge_account_events_drops_event_exactly_at_cutoff() {
656 let mut account = BaseAccount::new(cash_account_state(), true);
657 let mut at_cutoff = cash_account_state();
658 at_cutoff.ts_event = UnixNanos::from(200_000_000_000);
659 account.base_apply(at_cutoff);
660 let mut after_cutoff = cash_account_state();
661 after_cutoff.ts_event = UnixNanos::from(200_000_000_001);
662 account.base_apply(after_cutoff);
663
664 account.base_purge_account_events(UnixNanos::from(300_000_000_000), 100);
665
666 assert_eq!(account.events.len(), 1);
667 assert_eq!(account.events[0].ts_event, UnixNanos::from(200_000_000_001));
668 }
669
670 #[rstest]
671 fn test_base_purge_account_events_retains_future_event_without_overflow() {
672 let mut event = cash_account_state();
673 event.ts_event = UnixNanos::from(u64::MAX - 1);
674 let mut account = BaseAccount::new(event, true);
675
676 account.base_purge_account_events(UnixNanos::from(u64::MAX), 60);
677
678 assert_eq!(account.events.len(), 1);
679 }
680
681 #[rstest]
682 #[should_panic(
683 expected = r#"lhs_param: "event.account_id", rhs_param: "self.id", lhs: "OTHER-001", rhs: "SIM-001""#
684 )]
685 fn test_base_apply_panics_on_different_account_id() {
686 let mut account = BaseAccount::new(cash_account_state(), true);
687 let mut event = cash_account_state();
688 event.account_id = AccountId::from("OTHER-001");
689
690 account.base_apply(event);
691 }
692
693 fn usd_balances(total: &str) -> IndexMap<Currency, AccountBalance> {
694 let total = Money::from(total);
695 let mut balances = IndexMap::new();
696 balances.insert(
697 Currency::USD(),
698 AccountBalance::new(total, Money::zero(Currency::USD()), total),
699 );
700 balances
701 }
702
703 fn mismatched_usd() -> Currency {
704 Currency::new(
705 "USD",
706 Currency::USD().precision + 1,
707 840,
708 "United States dollar",
709 crate::enums::CurrencyType::Fiat,
710 )
711 }
712
713 #[rstest]
716 #[case::observed_currency(true)]
717 #[case::unobserved_currency(false)]
718 fn test_update_balance_locked_rejects_negative_without_mutation(#[case] observed: bool) {
719 let mut balances = if observed {
720 usd_balances("1000 USD")
721 } else {
722 IndexMap::new()
723 };
724 let balances_before = balances.clone();
725 let mut balances_locked = AHashMap::new();
726 let instrument_id = InstrumentId::from("AUD/USD.SIM");
727
728 let error = update_balance_locked(
729 &mut balances,
730 &mut balances_locked,
731 instrument_id,
732 Money::from("-1 USD"),
733 )
734 .unwrap_err();
735
736 assert_eq!(error.to_string(), "locked balance was negative: -1.00 USD");
737 assert!(balances_locked.is_empty());
738 assert_eq!(balances, balances_before);
739 }
740
741 #[rstest]
742 fn test_update_balance_locked_restores_prior_reservation_on_failure() {
743 let usd = Currency::USD();
744 let mut balances = usd_balances("1000 USD");
745 let stale_key = (InstrumentId::from("EUR/USD.SIM"), mismatched_usd());
746 let stale = Money::from_decimal(Decimal::from(10), mismatched_usd()).unwrap();
747 let mut balances_locked = AHashMap::from([(stale_key, stale)]);
748 let instrument_id = InstrumentId::from("AUD/USD.SIM");
749
750 let result = update_balance_locked(
753 &mut balances,
754 &mut balances_locked,
755 instrument_id,
756 Money::from("100 USD"),
757 );
758
759 assert!(result.is_err());
760 assert_eq!(balances_locked, AHashMap::from([(stale_key, stale)]));
761 assert_eq!(balances, usd_balances("1000 USD"));
762 assert_eq!(balances[&usd].free, Money::from("1000 USD"));
763 }
764
765 #[rstest]
766 fn test_balance_from_locks_clamps_reservations_to_total() {
767 let usd = Currency::USD();
768 let total = Money::from("100 USD");
769 let current = AccountBalance::new(total, Money::zero(usd), total);
770 let mut balances_locked = AHashMap::new();
771 balances_locked.insert(
772 (InstrumentId::from("AUD/USD.SIM"), usd),
773 Money::from("60 USD"),
774 );
775 balances_locked.insert(
776 (InstrumentId::from("EUR/USD.SIM"), usd),
777 Money::from("60 USD"),
778 );
779
780 let balance = balance_from_locks(current, &balances_locked).unwrap();
781
782 assert_eq!(balance.total, total);
783 assert_eq!(balance.locked, total);
784 assert_eq!(balance.free, Money::zero(usd));
785 }
786
787 #[rstest]
788 #[case::positive_total("1000 USD", "1000 USD", "0 USD")]
789 #[case::negative_total("-1000 USD", "0 USD", "-1000 USD")]
790 fn test_recalculate_balance_degrades_to_non_spendable_for_invalid_reservation(
791 #[case] total: &str,
792 #[case] expected_locked: &str,
793 #[case] expected_free: &str,
794 ) {
795 use crate::{enums::CurrencyType, types::Currency};
796
797 let usd = Currency::USD();
798 let total = Money::from(total);
799 let mut balances = IndexMap::new();
800 balances.insert(usd, AccountBalance::new(total, Money::zero(usd), total));
801 let mismatched_usd = Currency::new(
803 "USD",
804 usd.precision + 1,
805 840,
806 "United States dollar",
807 CurrencyType::Fiat,
808 );
809 let mut balances_locked = AHashMap::new();
810 balances_locked.insert(
811 (InstrumentId::from("AUD/USD.SIM"), mismatched_usd),
812 Money::from_decimal(Decimal::from(100), mismatched_usd).unwrap(),
813 );
814
815 recalculate_balance(&mut balances, &balances_locked, usd);
816
817 let balance = balances.get(&usd).expect("balance should be retained");
818 assert_eq!(balance.total, total);
819 assert_eq!(balance.locked, Money::from(expected_locked));
820 assert_eq!(balance.free, Money::from(expected_free));
821 }
822
823 #[rstest]
824 fn test_update_commissions_sub_canonical_raw_skipped() {
825 use crate::{
826 events::account::stubs::cash_account_state,
827 types::{Currency, Money},
828 };
829
830 let mut account = BaseAccount::new(cash_account_state(), true);
831 let usd = Currency::USD();
832
833 account.update_commissions(Money::from_raw(1, usd));
835
836 assert!(account.commission(&usd).is_none());
837 }
838
839 #[rstest]
840 fn test_commissions_returns_every_currency() {
841 let mut account = BaseAccount::new(cash_account_state(), true);
842 account.update_commissions(Money::from("2.50 USD"));
843 account.update_commissions(Money::from("1.25 AUD"));
844
845 let commissions = account.commissions();
846
847 assert_eq!(commissions.len(), 2);
848 assert_eq!(
849 commissions.get(&Currency::USD()),
850 Some(&Money::from("2.50 USD"))
851 );
852 assert_eq!(
853 commissions.get(&Currency::AUD()),
854 Some(&Money::from("1.25 AUD"))
855 );
856 }
857
858 #[rstest]
859 fn test_try_update_commissions_overflow_preserves_total() {
860 let mut account = BaseAccount::new(cash_account_state(), true);
861 let usd = Currency::USD();
862 let maximum = Money::from_raw(MONEY_RAW_MAX, usd);
863
864 account.try_update_commissions(maximum).unwrap();
865 let result = account.try_update_commissions(Money::from("0.01 USD"));
866
867 assert!(result.is_err());
868 assert_eq!(account.commission(&usd), Some(maximum));
869 }
870}