1use ahash::AHashMap;
22use indexmap::IndexMap;
23use nautilus_core::{
24 UnixNanos,
25 correctness::{
26 CorrectnessError, CorrectnessResult, FAILED, check_equal, check_predicate_false,
27 check_predicate_true,
28 },
29 datetime::secs_to_nanos,
30};
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33
34use crate::{
35 enums::{AccountType, LiquiditySide, OrderSide},
36 events::{AccountState, OrderFilled},
37 identifiers::{AccountId, InstrumentId},
38 instruments::{Instrument, InstrumentAny},
39 position::Position,
40 types::{AccountBalance, Currency, Money, Price, Quantity, money::MoneyRaw},
41};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
47)]
48pub struct BaseAccount {
49 pub id: AccountId,
50 pub account_type: AccountType,
51 pub base_currency: Option<Currency>,
52 pub calculate_account_state: bool,
53 pub events: Vec<AccountState>,
54 pub commissions: AHashMap<Currency, Money>,
55 pub balances: IndexMap<Currency, AccountBalance>,
56 pub balances_starting: IndexMap<Currency, Money>,
57}
58
59impl BaseAccount {
60 #[must_use]
62 pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
63 let mut balances_starting: IndexMap<Currency, Money> = IndexMap::new();
64 let mut balances: IndexMap<Currency, AccountBalance> = IndexMap::new();
65 event.balances.iter().for_each(|balance| {
66 balances_starting.insert(balance.currency, balance.total);
67 balances.insert(balance.currency, *balance);
68 });
69 Self {
70 id: event.account_id,
71 account_type: event.account_type,
72 base_currency: event.base_currency,
73 calculate_account_state,
74 events: vec![event],
75 commissions: AHashMap::new(),
76 balances,
77 balances_starting,
78 }
79 }
80
81 #[must_use]
82 pub(crate) fn clone_without_events(&self) -> Self {
83 Self {
84 id: self.id,
85 account_type: self.account_type,
86 base_currency: self.base_currency,
87 calculate_account_state: self.calculate_account_state,
88 events: Vec::new(),
89 commissions: self.commissions.clone(),
90 balances: self.balances.clone(),
91 balances_starting: self.balances_starting.clone(),
92 }
93 }
94
95 #[must_use]
101 pub fn base_balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
102 let currency = currency
103 .or(self.base_currency)
104 .expect("Currency must be specified");
105 self.balances.get(¤cy)
106 }
107
108 #[must_use]
114 pub fn base_balance_total(&self, currency: Option<Currency>) -> Option<Money> {
115 let currency = currency
116 .or(self.base_currency)
117 .expect("Currency must be specified");
118 let account_balance = self.balances.get(¤cy);
119 account_balance.map(|balance| balance.total)
120 }
121
122 #[must_use]
123 pub fn base_balances_total(&self) -> IndexMap<Currency, Money> {
124 self.balances
125 .iter()
126 .map(|(currency, balance)| (*currency, balance.total))
127 .collect()
128 }
129
130 #[must_use]
136 pub fn base_balance_free(&self, currency: Option<Currency>) -> Option<Money> {
137 let currency = currency
138 .or(self.base_currency)
139 .expect("Currency must be specified");
140 let account_balance = self.balances.get(¤cy);
141 account_balance.map(|balance| balance.free)
142 }
143
144 #[must_use]
145 pub fn base_balances_free(&self) -> IndexMap<Currency, Money> {
146 self.balances
147 .iter()
148 .map(|(currency, balance)| (*currency, balance.free))
149 .collect()
150 }
151
152 #[must_use]
158 pub fn base_balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
159 let currency = currency
160 .or(self.base_currency)
161 .expect("Currency must be specified");
162 let account_balance = self.balances.get(¤cy);
163 account_balance.map(|balance| balance.locked)
164 }
165
166 #[must_use]
167 pub fn base_balances_locked(&self) -> IndexMap<Currency, Money> {
168 self.balances
169 .iter()
170 .map(|(currency, balance)| (*currency, balance.locked))
171 .collect()
172 }
173
174 #[must_use]
175 pub fn base_last_event(&self) -> Option<AccountState> {
176 self.events.last().cloned()
177 }
178
179 pub fn update_balances(&mut self, balances: &[AccountBalance]) {
186 for balance in balances {
187 self.balances.insert(balance.currency, *balance);
188 }
189 }
190
191 pub fn update_commissions(&mut self, commission: Money) {
198 self.try_update_commissions(commission)
199 .expect("commission total exceeded Money bounds");
200 }
201
202 pub fn try_update_commissions(&mut self, commission: Money) -> anyhow::Result<()> {
208 let commission = commission.normalized();
210 if commission.is_zero() {
211 return Ok(());
212 }
213 let currency = commission.currency;
214 let total = self
215 .commissions
216 .get(¤cy)
217 .copied()
218 .map_or(Some(commission), |total| total.checked_add(commission))
219 .ok_or_else(|| anyhow::anyhow!("{currency} commission total exceeds Money bounds"))?;
220 self.commissions.insert(currency, total);
221 Ok(())
222 }
223
224 #[must_use]
226 pub fn commission(&self, currency: &Currency) -> Option<Money> {
227 self.commissions.get(currency).copied()
228 }
229
230 #[must_use]
232 pub fn commissions(&self) -> AHashMap<Currency, Money> {
233 self.commissions.clone()
234 }
235
236 pub fn base_apply(&mut self, event: AccountState) {
242 check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
243 self.update_balances(&event.balances);
244 self.events.push(event);
245 }
246
247 pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
255 let Ok(lookback_ns) = secs_to_nanos(lookback_secs as f64) else {
256 log::warn!(
257 "Cannot purge account events: lookback_secs {lookback_secs} is not representable in `u64` nanoseconds"
258 );
259 return;
260 };
261 let purge_cutoff = ts_now.checked_sub(lookback_ns);
262
263 let mut retained_events = Vec::new();
264
265 for event in &self.events {
266 if purge_cutoff.is_none_or(|cutoff| event.ts_event > cutoff) {
267 retained_events.push(event.clone());
268 }
269 }
270
271 if retained_events.is_empty() && !self.events.is_empty() {
273 retained_events.push(self.events.last().expect("events not empty").clone());
274 }
275
276 self.events = retained_events;
277 }
278
279 pub fn base_calculate_balance_locked(
285 &mut self,
286 instrument: &InstrumentAny,
287 side: OrderSide,
288 quantity: Quantity,
289 price: Price,
290 use_quote_for_inverse: Option<bool>,
291 ) -> anyhow::Result<Money> {
292 let base_currency = instrument
293 .base_currency()
294 .unwrap_or(instrument.quote_currency());
295 let quote_currency = instrument.quote_currency();
296 let amount = match side {
297 OrderSide::Buy => instrument
301 .try_calculate_notional_value(quantity, price, use_quote_for_inverse)?
302 .as_decimal()
303 .max(Decimal::ZERO),
304 OrderSide::Sell => quantity.as_decimal(),
305 };
306
307 if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
308 Ok(Money::from_decimal(amount, base_currency)?)
309 } else {
310 let currency = match side {
311 OrderSide::Buy => quote_currency,
312 OrderSide::Sell => base_currency,
313 };
314 Ok(Money::from_decimal(amount, currency)?)
315 }
316 }
317
318 pub fn base_calculate_pnls(
331 &self,
332 instrument: &InstrumentAny,
333 fill: &OrderFilled,
334 _position: Option<Position>,
335 ) -> anyhow::Result<Vec<Money>> {
336 let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
337 let base_currency = instrument.base_currency();
338
339 let fill_qty = fill.last_qty;
341 let notional = instrument.try_calculate_notional_value(fill_qty, fill.last_px, None)?;
342
343 if fill.order_side == OrderSide::Buy {
344 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
345 pnls.insert(
346 base_currency_value,
347 Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
348 );
349 }
350 pnls.insert(notional.currency, -notional);
351 } else {
352 if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
353 pnls.insert(
354 base_currency_value,
355 -Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
356 );
357 }
358 pnls.insert(notional.currency, notional);
359 }
360 Ok(pnls.into_values().collect())
361 }
362
363 pub fn base_calculate_commission(
370 &self,
371 instrument: &InstrumentAny,
372 last_qty: Quantity,
373 last_px: Price,
374 liquidity_side: LiquiditySide,
375 use_quote_for_inverse: Option<bool>,
376 ) -> anyhow::Result<Money> {
377 anyhow::ensure!(
378 liquidity_side != LiquiditySide::NoLiquiditySide,
379 "Invalid `LiquiditySide`: {liquidity_side}"
380 );
381 let notional =
382 instrument.try_calculate_notional_value(last_qty, last_px, use_quote_for_inverse)?;
383 let rate = match liquidity_side {
384 LiquiditySide::Maker => instrument.maker_fee(),
385 LiquiditySide::Taker => instrument.taker_fee(),
386 LiquiditySide::NoLiquiditySide => {
387 anyhow::bail!("Invalid `LiquiditySide`: {liquidity_side}")
388 }
389 };
390 let commission = notional
391 .as_decimal()
392 .checked_mul(rate)
393 .ok_or_else(|| anyhow::anyhow!("commission calculation overflow"))?;
394
395 Ok(Money::from_decimal(commission, notional.currency)?)
396 }
397}
398
399pub(crate) fn update_balance_locked(
406 balances: &mut IndexMap<Currency, AccountBalance>,
407 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
408 instrument_id: InstrumentId,
409 locked: Money,
410) {
411 assert!(locked.raw >= 0, "locked balance was negative: {locked}");
412 let currency = locked.currency;
413 if let Some(balance) = balances.get(¤cy)
414 && balance.currency.precision != currency.precision
415 {
416 log::error!(
417 "Cannot update {currency} reservation: precision {} differed from balance precision {}",
418 currency.precision,
419 balance.currency.precision
420 );
421 return;
422 }
423
424 balances_locked.insert((instrument_id, currency), locked);
425 recalculate_balance(balances, balances_locked, currency);
426}
427
428pub(crate) fn clear_balance_locked(
430 balances: &mut IndexMap<Currency, AccountBalance>,
431 balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
432 instrument_id: InstrumentId,
433) {
434 let currencies_to_recalc: Vec<Currency> = balances_locked
435 .keys()
436 .filter(|(id, _)| *id == instrument_id)
437 .map(|(_, currency)| *currency)
438 .collect();
439
440 for currency in ¤cies_to_recalc {
441 balances_locked.remove(&(instrument_id, *currency));
442 }
443
444 for currency in currencies_to_recalc {
445 recalculate_balance(balances, balances_locked, currency);
446 }
447}
448
449pub(crate) fn recalculate_balance(
454 balances: &mut IndexMap<Currency, AccountBalance>,
455 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
456 currency: Currency,
457) {
458 let current_balance = if let Some(balance) = balances.get(¤cy) {
459 *balance
460 } else {
461 log::debug!("Cannot recalculate balance when no current balance for {currency}");
462 return;
463 };
464
465 let new_balance = match balance_from_locks(current_balance, balances_locked) {
466 Ok(balance) => balance,
467 Err(e) => {
468 log::error!(
469 "Cannot recalculate {currency} balance from reservations: {e}; using a non-spendable balance"
470 );
471 non_spendable_balance(current_balance)
472 }
473 };
474
475 balances.insert(currency, new_balance);
476}
477
478pub(crate) fn balance_from_locks(
485 current_balance: AccountBalance,
486 balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
487) -> CorrectnessResult<AccountBalance> {
488 let currency = current_balance.currency;
489 let mut total_locked_raw: MoneyRaw = 0;
490
491 for locked in balances_locked
492 .values()
493 .filter(|locked| locked.currency == currency)
494 {
495 check_predicate_false(
496 locked.raw < 0,
497 &format!("locked balance was negative: {locked}"),
498 )?;
499 check_predicate_true(
500 locked.currency.precision == currency.precision,
501 &format!(
502 "locked balance precision {} differed from balance precision {} for {currency}",
503 locked.currency.precision, currency.precision
504 ),
505 )?;
506 total_locked_raw = total_locked_raw.saturating_add(locked.raw);
507 }
508
509 let total_raw = current_balance.total.raw;
510 let locked_raw = if total_raw >= 0 {
511 total_locked_raw.min(total_raw)
512 } else {
513 total_locked_raw
514 };
515 let free_raw =
516 total_raw
517 .checked_sub(locked_raw)
518 .ok_or_else(|| CorrectnessError::PredicateViolation {
519 message: format!(
520 "derived free balance overflowed for total {} and locked raw {locked_raw}",
521 current_balance.total
522 ),
523 })?;
524 let locked = Money::from_raw_checked(locked_raw, currency)?;
525 let free = Money::from_raw_checked(free_raw, currency)?;
526
527 AccountBalance::new_checked(current_balance.total, locked, free)
528}
529
530fn non_spendable_balance(current_balance: AccountBalance) -> AccountBalance {
531 let zero = Money::zero(current_balance.currency);
532 let (locked, free) = if current_balance.total.raw >= 0 {
533 (current_balance.total, zero)
534 } else {
535 (zero, current_balance.total)
536 };
537
538 AccountBalance {
539 currency: current_balance.currency,
540 total: current_balance.total,
541 locked,
542 free,
543 }
544}
545
546#[cfg(all(test, feature = "test-support"))]
547mod tests {
548 use rstest::rstest;
549
550 use super::*;
551 use crate::{events::account::stubs::cash_account_state, types::money::MONEY_RAW_MAX};
552
553 #[rstest]
554 fn test_base_purge_account_events_retains_latest_when_all_purged() {
555 use crate::{
556 enums::AccountType,
557 events::account::stubs::cash_account_state,
558 identifiers::stubs::{account_id, uuid4},
559 types::{Currency, stubs::stub_account_balance},
560 };
561
562 let mut account = BaseAccount::new(cash_account_state(), true);
563
564 let event1 = AccountState::new(
566 account_id(),
567 AccountType::Cash,
568 vec![stub_account_balance()],
569 vec![],
570 true,
571 uuid4(),
572 UnixNanos::from(100_000_000),
573 UnixNanos::from(100_000_000),
574 Some(Currency::USD()),
575 );
576 let event2 = AccountState::new(
577 account_id(),
578 AccountType::Cash,
579 vec![stub_account_balance()],
580 vec![],
581 true,
582 uuid4(),
583 UnixNanos::from(200_000_000),
584 UnixNanos::from(200_000_000),
585 Some(Currency::USD()),
586 );
587 let event3 = AccountState::new(
588 account_id(),
589 AccountType::Cash,
590 vec![stub_account_balance()],
591 vec![],
592 true,
593 uuid4(),
594 UnixNanos::from(300_000_000),
595 UnixNanos::from(300_000_000),
596 Some(Currency::USD()),
597 );
598
599 account.base_apply(event1);
600 account.base_apply(event2);
601 account.base_apply(event3.clone());
602
603 assert_eq!(account.events.len(), 4);
604
605 account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
606
607 assert_eq!(account.events.len(), 1);
608 assert_eq!(account.events[0].ts_event, event3.ts_event);
609 assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
610 }
611
612 #[rstest]
613 fn test_base_purge_account_events_retains_all_for_overflowing_lookback() {
614 let mut account = BaseAccount::new(cash_account_state(), true);
615 let mut event = cash_account_state();
616 event.ts_event = UnixNanos::from(1);
617 account.base_apply(event);
618
619 account.base_purge_account_events(UnixNanos::from(u64::MAX), u64::MAX);
620
621 assert_eq!(account.events.len(), 2);
622 }
623
624 #[rstest]
625 fn test_base_purge_account_events_retains_future_event_without_overflow() {
626 let mut event = cash_account_state();
627 event.ts_event = UnixNanos::from(u64::MAX - 1);
628 let mut account = BaseAccount::new(event, true);
629
630 account.base_purge_account_events(UnixNanos::from(u64::MAX), 60);
631
632 assert_eq!(account.events.len(), 1);
633 }
634
635 #[rstest]
636 fn test_update_commissions_sub_canonical_raw_skipped() {
637 use crate::{
638 events::account::stubs::cash_account_state,
639 types::{Currency, Money},
640 };
641
642 let mut account = BaseAccount::new(cash_account_state(), true);
643 let usd = Currency::USD();
644
645 account.update_commissions(Money::from_raw(1, usd));
647
648 assert!(account.commission(&usd).is_none());
649 }
650
651 #[rstest]
652 fn test_try_update_commissions_overflow_preserves_total() {
653 let mut account = BaseAccount::new(cash_account_state(), true);
654 let usd = Currency::USD();
655 let maximum = Money::from_raw(MONEY_RAW_MAX, usd);
656
657 account.try_update_commissions(maximum).unwrap();
658 let result = account.try_update_commissions(Money::from("0.01 USD"));
659
660 assert!(result.is_err());
661 assert_eq!(account.commission(&usd), Some(maximum));
662 }
663}