Skip to main content

nautilus_model/accounts/
base.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Base traits and common types shared by all account implementations.
17//!
18//! Concrete account types (`CashAccount`, `MarginAccount`, etc.) build on the abstractions defined
19//! in this file.
20
21use 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    /// Creates a new [`BaseAccount`] instance.
61    #[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    /// Returns a reference to the `AccountBalance` for the specified currency, or `None` if absent.
96    ///
97    /// # Panics
98    ///
99    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
100    #[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(&currency)
106    }
107
108    /// Returns the total `Money` balance for the specified currency, or `None` if absent.
109    ///
110    /// # Panics
111    ///
112    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
113    #[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(&currency);
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    /// Returns the free `Money` balance for the specified currency, or `None` if absent.
131    ///
132    /// # Panics
133    ///
134    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
135    #[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(&currency);
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    /// Returns the locked `Money` balance for the specified currency, or `None` if absent.
153    ///
154    /// # Panics
155    ///
156    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
157    #[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(&currency);
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    /// Updates the account balances with the provided list of `AccountBalance` instances.
180    ///
181    /// Note: This method does NOT validate negative balances. Derived account types
182    /// (`CashAccount`, `MarginAccount`) should perform their own validation in `apply()`:
183    /// - `MarginAccount`: allows negative balances (normal for margin trading)
184    /// - `CashAccount`: rejects negative unless `allow_borrowing` is true
185    pub fn update_balances(&mut self, balances: &[AccountBalance]) {
186        for balance in balances {
187            self.balances.insert(balance.currency, *balance);
188        }
189    }
190
191    /// Updates the account commissions with the provided amount.
192    ///
193    /// # Panics
194    ///
195    /// Panics if the accumulated commission exceeds [`Money`] bounds. Operational callers should
196    /// use [`Self::try_update_commissions`] when the input is not already known to fit.
197    pub fn update_commissions(&mut self, commission: Money) {
198        self.try_update_commissions(commission)
199            .expect("commission total exceeded Money bounds");
200    }
201
202    /// Updates the account commissions with the provided amount.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the accumulated commission exceeds [`Money`] bounds.
207    pub fn try_update_commissions(&mut self, commission: Money) -> anyhow::Result<()> {
208        // TODO: Remove once from_raw enforces canonical precision alignment (v2)
209        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(&currency)
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    /// Returns the total commission for the specified currency.
225    #[must_use]
226    pub fn commission(&self, currency: &Currency) -> Option<Money> {
227        self.commissions.get(currency).copied()
228    }
229
230    /// Returns a map of all commissions by currency.
231    #[must_use]
232    pub fn commissions(&self) -> AHashMap<Currency, Money> {
233        self.commissions.clone()
234    }
235
236    /// Applies an [`AccountState`] event, updating balances.
237    ///
238    /// # Panics
239    ///
240    /// Panics if `event.account_id` does not match this account's ID.
241    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    /// Purges all account state events which are outside the lookback window.
248    ///
249    /// Guaranteed to retain at least the latest event.
250    ///
251    /// # Panics
252    ///
253    /// Panics if the purging implementation is changed and all events are purged.
254    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        // Guarantee ≥ 1 event
272        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    /// Calculates the amount of balance to lock for a new order based on the given side, quantity, and price.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if the locked amount cannot be represented in the target currency.
284    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            // A buy at a negative price settles as a credit rather than a debit, so it
298            // reserves nothing. Clamping per order rather than after aggregation keeps a
299            // negative-price buy from financing a positive-price one before either fills.
300            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    /// Calculates profit and loss amounts for a filled order.
319    ///
320    /// For cash accounts, this calculates the balance impact of a fill:
321    /// - BUY: gain base currency quantity, lose quote currency notional.
322    /// - SELL: lose base currency quantity, gain quote currency notional.
323    ///
324    /// Note: Unlike betting accounts, cash accounts do NOT cap to position quantity.
325    /// The full fill quantity is used for PnL calculation.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if a PnL amount cannot be represented in the target currency.
330    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        // No quantity capping (betting accounts cap to position qty, cash accounts don't)
340        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    /// Calculates commission fees for a filled order.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if `liquidity_side` is invalid, the notional value cannot be calculated,
368    /// or the commission cannot be represented in the target currency.
369    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
399/// Updates the locked balance for the given instrument and currency, then recalculates the
400/// account balance for that currency from all per-(instrument, currency) locks.
401///
402/// # Panics
403///
404/// Panics if `locked` is negative.
405pub(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(&currency)
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
428/// Clears all locked balances for the given instrument ID, recalculating each affected currency.
429pub(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 &currencies_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
449/// Recalculates the account balance for the specified currency based on per-instrument locks.
450///
451/// Sums all per-instrument locked amounts for the currency and updates the balance.
452/// If the total locked exceeds the total balance, clamps to total (free = 0).
453pub(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(&currency) {
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
478/// Derives an account balance from its total and all local reservations for its currency.
479///
480/// # Errors
481///
482/// Returns an error if a reservation is negative, uses a different fixed precision, or the
483/// derived locked or free balance exceeds [`Money`] bounds.
484pub(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        // Create events with different timestamps manually
565        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        // Sub-canonical raw (1 < tick size for USD precision 2) normalizes to zero
646        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}