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    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/// Represents the account state shared by every account type.
41#[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    /// The account ID.
48    pub id: AccountId,
49    /// The type of the account (e.g., margin, spot, etc.).
50    pub account_type: AccountType,
51    /// The base currency for the account, if applicable.
52    pub base_currency: Option<Currency>,
53    /// Indicates if the account state is recalculated from order fills
54    /// (as opposed to taken from venue reports).
55    pub calculate_account_state: bool,
56    /// The account state events, oldest first.
57    pub events: Vec<AccountState>,
58    /// The commissions charged so far, keyed by currency.
59    pub commissions: AHashMap<Currency, Money>,
60    /// The current balances in the account, keyed by currency.
61    pub balances: IndexMap<Currency, AccountBalance>,
62    /// The total balances the account started with, keyed by currency.
63    pub balances_starting: IndexMap<Currency, Money>,
64}
65
66impl BaseAccount {
67    /// Creates a new [`BaseAccount`] instance.
68    #[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    /// Returns a reference to the `AccountBalance` for the specified currency, or `None` if absent.
103    ///
104    /// # Panics
105    ///
106    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
107    #[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(&currency)
113    }
114
115    /// Returns the total `Money` balance for the specified currency, or `None` if absent.
116    ///
117    /// # Panics
118    ///
119    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
120    #[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    /// Returns the free `Money` balance for the specified currency, or `None` if absent.
134    ///
135    /// # Panics
136    ///
137    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
138    #[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    /// Returns the locked `Money` balance for the specified currency, or `None` if absent.
152    ///
153    /// # Panics
154    ///
155    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
156    #[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    /// Updates the account balances with the provided list of `AccountBalance` instances.
175    ///
176    /// Note: This method does NOT validate negative balances. Derived account types
177    /// (`CashAccount`, `MarginAccount`) should perform their own validation in `apply()`:
178    /// - `MarginAccount`: allows negative balances (normal for margin trading)
179    /// - `CashAccount`: rejects negative unless `allow_borrowing` is true
180    pub fn update_balances(&mut self, balances: &[AccountBalance]) {
181        for balance in balances {
182            self.balances.insert(balance.currency, *balance);
183        }
184    }
185
186    /// Updates the account commissions with the provided amount.
187    ///
188    /// # Panics
189    ///
190    /// Panics if the accumulated commission exceeds [`Money`] bounds. Operational callers should
191    /// use [`Self::try_update_commissions`] when the input is not already known to fit.
192    pub fn update_commissions(&mut self, commission: Money) {
193        self.try_update_commissions(commission)
194            .expect("commission total exceeded Money bounds");
195    }
196
197    /// Updates the account commissions with the provided amount.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if the accumulated commission exceeds [`Money`] bounds.
202    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(&currency)
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    /// Returns the total commission for the specified currency.
218    #[must_use]
219    pub fn commission(&self, currency: &Currency) -> Option<Money> {
220        self.commissions.get(currency).copied()
221    }
222
223    /// Returns a map of all commissions by currency.
224    #[must_use]
225    pub fn commissions(&self) -> AHashMap<Currency, Money> {
226        self.commissions.clone()
227    }
228
229    /// Checks the event belongs to this account.
230    ///
231    /// Concrete accounts call this before mutating any state, so a foreign event is rejected
232    /// rather than partially applied.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if `event.account_id` does not match this account's ID.
237    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    /// Applies an [`AccountState`] event, updating balances.
248    ///
249    /// # Panics
250    ///
251    /// Panics if `event.account_id` does not match this account's ID. Every account rejects a
252    /// foreign event before reaching here, so this remains an internal invariant.
253    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    /// Purges all account state events which are outside the lookback window.
260    ///
261    /// Guaranteed to retain at least the latest event.
262    ///
263    /// # Panics
264    ///
265    /// Panics if the purging implementation is changed and all events are purged.
266    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        // Guarantee ≥ 1 event
284        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    /// Calculates the amount of balance to lock for a new order based on the given side, quantity, and price.
292    ///
293    /// Projects the reservation at native [`Money`] precision with midpoint-to-even rounding.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if an input or the locked amount exceeds the accounting bounds.
298    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            // A buy at a negative price settles as a credit rather than a debit, so it
308            // reserves nothing. Clamping per order rather than after aggregation keeps a
309            // negative-price buy from financing a positive-price one before either fills.
310            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            // No modeled base asset means nothing to reserve; inventory is venue-enforced.
316            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    /// Calculates profit and loss amounts for a filled order.
324    ///
325    /// For cash accounts, this calculates the balance impact of a fill:
326    /// - BUY: gain base currency quantity, lose quote currency notional.
327    /// - SELL: lose base currency quantity, gain quote currency notional.
328    ///
329    /// Note: Unlike betting accounts, cash accounts do NOT cap to position quantity.
330    /// The full fill quantity is used for PnL calculation.
331    ///
332    /// Projects each cash flow at native [`Money`] precision with midpoint-to-even rounding.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if an input, cash flow, or resulting position exceeds the accounting bounds.
337    #[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        // No quantity capping (betting accounts cap to position qty, cash accounts don't)
357        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    /// Calculates commission fees for a filled order from explicitly resolved fee rates.
381    ///
382    /// Fee policy belongs to the account-owned schedule, not the instrument. Callers must
383    /// resolve `fee_rates` for the instrument (including any exact override) before calling.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if `liquidity_side` is invalid, the notional value cannot be calculated,
388    /// or the commission cannot be represented in the target currency.
389    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
432/// Updates the locked balance for the given instrument and currency, then recalculates the
433/// account balance for that currency from all per-(instrument, currency) locks.
434///
435/// The reservation is recorded without a balance when the currency has no observed balance yet,
436/// so a later balance report derives from it.
437///
438/// # Errors
439///
440/// Returns an error if `locked` is negative, its precision differs from the balance precision,
441/// or the reservations cannot produce a valid balance. Balances and reservations are left
442/// unchanged when an error is returned.
443pub(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(&currency).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            // Restore the prior reservation so a rejected update leaves nothing behind
478            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
487/// Clears all locked balances for the given instrument ID, recalculating each affected currency.
488pub(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 &currencies_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
508/// Recalculates the account balance for the specified currency based on per-instrument locks.
509///
510/// Sums all per-instrument locked amounts for the currency and updates the balance.
511/// If the total locked exceeds the total balance, clamps to total (free = 0).
512pub(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(&currency) {
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
537/// Derives an account balance from its total and all local reservations for its currency.
538///
539/// # Errors
540///
541/// Returns an error if a reservation is negative, uses a different fixed precision, or the
542/// derived locked or free balance exceeds [`Money`] bounds.
543pub(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        // Create events with different timestamps manually
633        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    // The observed case also exercises `balance_from_locks`; the unobserved case reaches the
752    // early return, so only the guard in `update_balance_locked` can reject it.
753    #[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        // A reservation that is valid on its own, but cannot derive a balance alongside the
789        // stale entry already recorded for this currency
790        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        // A reservation at a differing precision cannot derive a valid balance
840        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}