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::{
26        CorrectnessError, CorrectnessResult, FAILED, check_equal, check_predicate_false,
27        check_predicate_true,
28    },
29};
30use rust_decimal::Decimal;
31use serde::{Deserialize, Serialize};
32
33use crate::{
34    enums::{AccountType, LiquiditySide, OrderSide},
35    events::{AccountState, OrderFilled},
36    identifiers::{AccountId, InstrumentId},
37    instruments::{Instrument, InstrumentAny},
38    position::Position,
39    types::{AccountBalance, Currency, Money, Price, Quantity},
40};
41
42/// Represents the account state shared by every account type.
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    /// The account ID.
50    pub id: AccountId,
51    /// The type of the account (e.g., margin, spot, etc.).
52    pub account_type: AccountType,
53    /// The base currency for the account, if applicable.
54    pub base_currency: Option<Currency>,
55    /// Indicates if the account state is recalculated from order fills
56    /// (as opposed to taken from venue reports).
57    pub calculate_account_state: bool,
58    /// The account state events, oldest first.
59    pub events: Vec<AccountState>,
60    /// The commissions charged so far, keyed by currency.
61    pub commissions: AHashMap<Currency, Money>,
62    /// The current balances in the account, keyed by currency.
63    pub balances: IndexMap<Currency, AccountBalance>,
64    /// The total balances the account started with, keyed by currency.
65    pub balances_starting: IndexMap<Currency, Money>,
66}
67
68impl BaseAccount {
69    /// Creates a new [`BaseAccount`] instance.
70    #[must_use]
71    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
72        let mut balances_starting: IndexMap<Currency, Money> = IndexMap::new();
73        let mut balances: IndexMap<Currency, AccountBalance> = IndexMap::new();
74        event.balances.iter().for_each(|balance| {
75            balances_starting.insert(balance.currency, balance.total);
76            balances.insert(balance.currency, *balance);
77        });
78        Self {
79            id: event.account_id,
80            account_type: event.account_type,
81            base_currency: event.base_currency,
82            calculate_account_state,
83            events: vec![event],
84            commissions: AHashMap::new(),
85            balances,
86            balances_starting,
87        }
88    }
89
90    #[must_use]
91    pub(crate) fn clone_without_events(&self) -> Self {
92        Self {
93            id: self.id,
94            account_type: self.account_type,
95            base_currency: self.base_currency,
96            calculate_account_state: self.calculate_account_state,
97            events: Vec::new(),
98            commissions: self.commissions.clone(),
99            balances: self.balances.clone(),
100            balances_starting: self.balances_starting.clone(),
101        }
102    }
103
104    /// Returns a reference to the `AccountBalance` for the specified currency, or `None` if absent.
105    ///
106    /// # Panics
107    ///
108    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
109    #[must_use]
110    pub fn base_balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
111        let currency = currency
112            .or(self.base_currency)
113            .expect("Currency must be specified");
114        self.balances.get(&currency)
115    }
116
117    /// Returns the total `Money` balance for the specified currency, or `None` if absent.
118    ///
119    /// # Panics
120    ///
121    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
122    #[must_use]
123    pub fn base_balance_total(&self, currency: Option<Currency>) -> Option<Money> {
124        self.base_balance(currency).map(|balance| balance.total)
125    }
126
127    #[must_use]
128    pub fn base_balances_total(&self) -> IndexMap<Currency, Money> {
129        self.balances
130            .iter()
131            .map(|(currency, balance)| (*currency, balance.total))
132            .collect()
133    }
134
135    /// Returns the free `Money` balance for the specified currency, or `None` if absent.
136    ///
137    /// # Panics
138    ///
139    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
140    #[must_use]
141    pub fn base_balance_free(&self, currency: Option<Currency>) -> Option<Money> {
142        self.base_balance(currency).map(|balance| balance.free)
143    }
144
145    #[must_use]
146    pub fn base_balances_free(&self) -> IndexMap<Currency, Money> {
147        self.balances
148            .iter()
149            .map(|(currency, balance)| (*currency, balance.free))
150            .collect()
151    }
152
153    /// Returns the locked `Money` balance for the specified currency, or `None` if absent.
154    ///
155    /// # Panics
156    ///
157    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
158    #[must_use]
159    pub fn base_balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
160        self.base_balance(currency).map(|balance| balance.locked)
161    }
162
163    #[must_use]
164    pub fn base_balances_locked(&self) -> IndexMap<Currency, Money> {
165        self.balances
166            .iter()
167            .map(|(currency, balance)| (*currency, balance.locked))
168            .collect()
169    }
170
171    #[must_use]
172    pub fn base_last_event(&self) -> Option<AccountState> {
173        self.events.last().cloned()
174    }
175
176    /// Updates the account balances with the provided list of `AccountBalance` instances.
177    ///
178    /// Note: This method does NOT validate negative balances. Derived account types
179    /// (`CashAccount`, `MarginAccount`) should perform their own validation in `apply()`:
180    /// - `MarginAccount`: allows negative balances (normal for margin trading)
181    /// - `CashAccount`: rejects negative unless `allow_borrowing` is true
182    pub fn update_balances(&mut self, balances: &[AccountBalance]) {
183        for balance in balances {
184            self.balances.insert(balance.currency, *balance);
185        }
186    }
187
188    /// Updates the account commissions with the provided amount.
189    ///
190    /// # Panics
191    ///
192    /// Panics if the accumulated commission exceeds [`Money`] bounds. Operational callers should
193    /// use [`Self::try_update_commissions`] when the input is not already known to fit.
194    pub fn update_commissions(&mut self, commission: Money) {
195        self.try_update_commissions(commission)
196            .expect("commission total exceeded Money bounds");
197    }
198
199    /// Updates the account commissions with the provided amount.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the accumulated commission exceeds [`Money`] bounds.
204    pub fn try_update_commissions(&mut self, commission: Money) -> anyhow::Result<()> {
205        // TODO: Remove once from_raw enforces canonical precision alignment (v2)
206        let commission = commission.normalized();
207        if commission.is_zero() {
208            return Ok(());
209        }
210        let currency = commission.currency;
211        let total = self
212            .commissions
213            .get(&currency)
214            .copied()
215            .map_or(Some(commission), |total| total.checked_add(commission))
216            .ok_or_else(|| anyhow::anyhow!("{currency} commission total exceeds Money bounds"))?;
217        self.commissions.insert(currency, total);
218        Ok(())
219    }
220
221    /// Returns the total commission for the specified currency.
222    #[must_use]
223    pub fn commission(&self, currency: &Currency) -> Option<Money> {
224        self.commissions.get(currency).copied()
225    }
226
227    /// Returns a map of all commissions by currency.
228    #[must_use]
229    pub fn commissions(&self) -> AHashMap<Currency, Money> {
230        self.commissions.clone()
231    }
232
233    /// Checks the event belongs to this account.
234    ///
235    /// Concrete accounts call this before mutating any state, so a foreign event is rejected
236    /// rather than partially applied.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if `event.account_id` does not match this account's ID.
241    pub(crate) fn check_event_account_id(&self, event: &AccountState) -> anyhow::Result<()> {
242        anyhow::ensure!(
243            event.account_id == self.id,
244            "Account event had a different account ID: expected {}, received {}",
245            self.id,
246            event.account_id
247        );
248        Ok(())
249    }
250
251    /// Applies an [`AccountState`] event, updating balances.
252    ///
253    /// # Panics
254    ///
255    /// Panics if `event.account_id` does not match this account's ID. Every account rejects a
256    /// foreign event before reaching here, so this remains an internal invariant.
257    pub fn base_apply(&mut self, event: AccountState) {
258        check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
259        self.update_balances(&event.balances);
260        self.events.push(event);
261    }
262
263    /// Purges all account state events which are outside the lookback window.
264    ///
265    /// Guaranteed to retain at least the latest event.
266    ///
267    /// # Panics
268    ///
269    /// Panics if the purging implementation is changed and all events are purged.
270    pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
271        let Ok(lookback_ns) = DurationNanos::try_from_secs(lookback_secs) else {
272            log::warn!(
273                "Cannot purge account events: lookback_secs {lookback_secs} is not representable in `u64` nanoseconds"
274            );
275            return;
276        };
277        let purge_cutoff = ts_now.checked_sub(lookback_ns);
278
279        let mut retained_events = Vec::new();
280
281        for event in &self.events {
282            if purge_cutoff.is_none_or(|cutoff| event.ts_event > cutoff) {
283                retained_events.push(event.clone());
284            }
285        }
286
287        // Guarantee ≥ 1 event
288        if retained_events.is_empty() && !self.events.is_empty() {
289            retained_events.push(self.events.last().expect("events not empty").clone());
290        }
291
292        self.events = retained_events;
293    }
294
295    /// Calculates the amount of balance to lock for a new order based on the given side, quantity, and price.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if the locked amount cannot be represented in the target currency.
300    pub fn base_calculate_balance_locked(
301        &self,
302        instrument: &InstrumentAny,
303        side: OrderSide,
304        quantity: Quantity,
305        price: Price,
306        use_quote_for_inverse: Option<bool>,
307    ) -> anyhow::Result<Money> {
308        let base_currency = instrument
309            .base_currency()
310            .unwrap_or(instrument.quote_currency());
311        let quote_currency = instrument.quote_currency();
312        let amount = match side {
313            // A buy at a negative price settles as a credit rather than a debit, so it
314            // reserves nothing. Clamping per order rather than after aggregation keeps a
315            // negative-price buy from financing a positive-price one before either fills.
316            OrderSide::Buy => instrument
317                .try_calculate_notional_value(quantity, price, use_quote_for_inverse)?
318                .as_decimal()
319                .max(Decimal::ZERO),
320            OrderSide::Sell => quantity.as_decimal(),
321        };
322
323        if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
324            Ok(Money::from_decimal(amount, base_currency)?)
325        } else {
326            let currency = match side {
327                OrderSide::Buy => quote_currency,
328                OrderSide::Sell => base_currency,
329            };
330            Ok(Money::from_decimal(amount, currency)?)
331        }
332    }
333
334    /// Calculates profit and loss amounts for a filled order.
335    ///
336    /// For cash accounts, this calculates the balance impact of a fill:
337    /// - BUY: gain base currency quantity, lose quote currency notional.
338    /// - SELL: lose base currency quantity, gain quote currency notional.
339    ///
340    /// Note: Unlike betting accounts, cash accounts do NOT cap to position quantity.
341    /// The full fill quantity is used for PnL calculation.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if a PnL amount cannot be represented in the target currency.
346    pub fn base_calculate_pnls(
347        &self,
348        instrument: &InstrumentAny,
349        fill: &OrderFilled,
350        _position: Option<Position>,
351    ) -> anyhow::Result<Vec<Money>> {
352        let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
353        let base_currency = instrument.base_currency();
354
355        // No quantity capping (betting accounts cap to position qty, cash accounts don't)
356        let fill_qty = fill.last_qty;
357        let notional = instrument.try_calculate_notional_value(fill_qty, fill.last_px, None)?;
358
359        if fill.order_side == OrderSide::Buy {
360            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
361                pnls.insert(
362                    base_currency_value,
363                    Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
364                );
365            }
366            pnls.insert(notional.currency, -notional);
367        } else {
368            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
369                pnls.insert(
370                    base_currency_value,
371                    -Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
372                );
373            }
374            pnls.insert(notional.currency, notional);
375        }
376        Ok(pnls.into_values().collect())
377    }
378
379    /// Calculates commission fees for a filled order.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if `liquidity_side` is invalid, the notional value cannot be calculated,
384    /// or the commission cannot be represented in the target currency.
385    pub fn base_calculate_commission(
386        &self,
387        instrument: &InstrumentAny,
388        last_qty: Quantity,
389        last_px: Price,
390        liquidity_side: LiquiditySide,
391        use_quote_for_inverse: Option<bool>,
392    ) -> anyhow::Result<Money> {
393        anyhow::ensure!(
394            liquidity_side != LiquiditySide::NoLiquiditySide,
395            "Invalid `LiquiditySide`: {liquidity_side}"
396        );
397        let notional =
398            instrument.try_calculate_notional_value(last_qty, last_px, use_quote_for_inverse)?;
399        let rate = match liquidity_side {
400            LiquiditySide::Maker => instrument.maker_fee(),
401            LiquiditySide::Taker => instrument.taker_fee(),
402            LiquiditySide::NoLiquiditySide => {
403                anyhow::bail!("Invalid `LiquiditySide`: {liquidity_side}")
404            }
405        };
406        let commission = notional
407            .as_decimal()
408            .checked_mul(rate)
409            .ok_or_else(|| anyhow::anyhow!("commission calculation overflow"))?;
410
411        Ok(Money::from_decimal(commission, notional.currency)?)
412    }
413}
414
415/// Updates the locked balance for the given instrument and currency, then recalculates the
416/// account balance for that currency from all per-(instrument, currency) locks.
417///
418/// The reservation is recorded without a balance when the currency has no observed balance yet,
419/// so a later balance report derives from it.
420///
421/// # Errors
422///
423/// Returns an error if `locked` is negative, its precision differs from the balance precision,
424/// or the reservations cannot produce a valid balance. Balances and reservations are left
425/// unchanged when an error is returned.
426pub(crate) fn update_balance_locked(
427    balances: &mut IndexMap<Currency, AccountBalance>,
428    balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
429    instrument_id: InstrumentId,
430    locked: Money,
431) -> anyhow::Result<()> {
432    anyhow::ensure!(
433        !locked.is_negative(),
434        "locked balance was negative: {locked}"
435    );
436
437    let currency = locked.currency;
438    let key = (instrument_id, currency);
439
440    let Some(current_balance) = balances.get(&currency).copied() else {
441        balances_locked.insert(key, locked);
442        return Ok(());
443    };
444
445    anyhow::ensure!(
446        current_balance.currency.precision == currency.precision,
447        "Cannot update {currency} reservation: precision {} differed from balance precision {}",
448        currency.precision,
449        current_balance.currency.precision
450    );
451
452    let previous = balances_locked.insert(key, locked);
453
454    match balance_from_locks(current_balance, balances_locked) {
455        Ok(balance) => {
456            balances.insert(currency, balance);
457            Ok(())
458        }
459        Err(e) => {
460            // Restore the prior reservation so a rejected update leaves nothing behind
461            match previous {
462                Some(previous) => balances_locked.insert(key, previous),
463                None => balances_locked.remove(&key),
464            };
465            Err(e.into())
466        }
467    }
468}
469
470/// Clears all locked balances for the given instrument ID, recalculating each affected currency.
471pub(crate) fn clear_balance_locked(
472    balances: &mut IndexMap<Currency, AccountBalance>,
473    balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
474    instrument_id: InstrumentId,
475) {
476    let currencies_to_recalc: Vec<Currency> = balances_locked
477        .keys()
478        .filter(|(id, _)| *id == instrument_id)
479        .map(|(_, currency)| *currency)
480        .collect();
481
482    for currency in &currencies_to_recalc {
483        balances_locked.remove(&(instrument_id, *currency));
484    }
485
486    for currency in currencies_to_recalc {
487        recalculate_balance(balances, balances_locked, currency);
488    }
489}
490
491/// Recalculates the account balance for the specified currency based on per-instrument locks.
492///
493/// Sums all per-instrument locked amounts for the currency and updates the balance.
494/// If the total locked exceeds the total balance, clamps to total (free = 0).
495pub(crate) fn recalculate_balance(
496    balances: &mut IndexMap<Currency, AccountBalance>,
497    balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
498    currency: Currency,
499) {
500    let current_balance = if let Some(balance) = balances.get(&currency) {
501        *balance
502    } else {
503        log::debug!("Cannot recalculate balance when no current balance for {currency}");
504        return;
505    };
506
507    let new_balance = match balance_from_locks(current_balance, balances_locked) {
508        Ok(balance) => balance,
509        Err(e) => {
510            log::error!(
511                "Cannot recalculate {currency} balance from reservations: {e}; using a non-spendable balance"
512            );
513            non_spendable_balance(current_balance)
514        }
515    };
516
517    balances.insert(currency, new_balance);
518}
519
520/// Derives an account balance from its total and all local reservations for its currency.
521///
522/// # Errors
523///
524/// Returns an error if a reservation is negative, uses a different fixed precision, or the
525/// derived locked or free balance exceeds [`Money`] bounds.
526pub(crate) fn balance_from_locks(
527    current_balance: AccountBalance,
528    balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
529) -> CorrectnessResult<AccountBalance> {
530    let currency = current_balance.currency;
531    let mut locked_total = Money::zero(currency);
532
533    for locked in balances_locked
534        .values()
535        .filter(|locked| locked.currency == currency)
536    {
537        check_predicate_false(
538            locked.is_negative(),
539            &format!("locked balance was negative: {locked}"),
540        )?;
541        check_predicate_true(
542            locked.currency.precision == currency.precision,
543            &format!(
544                "locked balance precision {} differed from balance precision {} for {currency}",
545                locked.currency.precision, currency.precision
546            ),
547        )?;
548
549        let reservation = if current_balance.total.is_negative() {
550            *locked
551        } else {
552            (*locked).min(current_balance.total - locked_total)
553        };
554
555        locked_total = locked_total.checked_add(reservation).ok_or_else(|| {
556            CorrectnessError::PredicateViolation {
557                message: format!("derived locked balance exceeded Money bounds for {currency}"),
558            }
559        })?;
560    }
561
562    let free = current_balance
563        .total
564        .checked_sub(locked_total)
565        .ok_or_else(|| CorrectnessError::PredicateViolation {
566            message: format!(
567                "derived free balance exceeded Money bounds for total {} and locked {locked_total}",
568                current_balance.total
569            ),
570        })?;
571
572    AccountBalance::new_checked(current_balance.total, locked_total, free)
573}
574
575fn non_spendable_balance(current_balance: AccountBalance) -> AccountBalance {
576    let zero = Money::zero(current_balance.currency);
577
578    let (locked, free) = if current_balance.total.is_negative() {
579        (zero, current_balance.total)
580    } else {
581        (current_balance.total, zero)
582    };
583
584    AccountBalance {
585        currency: current_balance.currency,
586        total: current_balance.total,
587        locked,
588        free,
589    }
590}
591
592#[cfg(all(test, feature = "test-support"))]
593mod tests {
594    use rstest::rstest;
595
596    use super::*;
597    use crate::{events::account::stubs::cash_account_state, types::money::MONEY_RAW_MAX};
598
599    #[rstest]
600    fn test_base_purge_account_events_retains_latest_when_all_purged() {
601        use crate::{
602            enums::AccountType,
603            events::account::stubs::cash_account_state,
604            identifiers::stubs::{account_id, uuid4},
605            types::{Currency, stubs::stub_account_balance},
606        };
607
608        let mut account = BaseAccount::new(cash_account_state(), true);
609
610        // Create events with different timestamps manually
611        let event1 = AccountState::new(
612            account_id(),
613            AccountType::Cash,
614            vec![stub_account_balance()],
615            vec![],
616            true,
617            uuid4(),
618            UnixNanos::from(100_000_000),
619            UnixNanos::from(100_000_000),
620            Some(Currency::USD()),
621        );
622        let event2 = AccountState::new(
623            account_id(),
624            AccountType::Cash,
625            vec![stub_account_balance()],
626            vec![],
627            true,
628            uuid4(),
629            UnixNanos::from(200_000_000),
630            UnixNanos::from(200_000_000),
631            Some(Currency::USD()),
632        );
633        let event3 = AccountState::new(
634            account_id(),
635            AccountType::Cash,
636            vec![stub_account_balance()],
637            vec![],
638            true,
639            uuid4(),
640            UnixNanos::from(300_000_000),
641            UnixNanos::from(300_000_000),
642            Some(Currency::USD()),
643        );
644
645        account.base_apply(event1);
646        account.base_apply(event2);
647        account.base_apply(event3.clone());
648
649        assert_eq!(account.events.len(), 4);
650
651        account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
652
653        assert_eq!(account.events.len(), 1);
654        assert_eq!(account.events[0].ts_event, event3.ts_event);
655        assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
656    }
657
658    #[rstest]
659    fn test_base_purge_account_events_retains_all_for_overflowing_lookback() {
660        let mut account = BaseAccount::new(cash_account_state(), true);
661        let mut event = cash_account_state();
662        event.ts_event = UnixNanos::from(1);
663        account.base_apply(event);
664
665        account.base_purge_account_events(UnixNanos::from(u64::MAX), u64::MAX);
666
667        assert_eq!(account.events.len(), 2);
668    }
669
670    #[rstest]
671    fn test_base_purge_account_events_drops_event_exactly_at_cutoff() {
672        let mut account = BaseAccount::new(cash_account_state(), true);
673        let mut at_cutoff = cash_account_state();
674        at_cutoff.ts_event = UnixNanos::from(200_000_000_000);
675        account.base_apply(at_cutoff);
676        let mut after_cutoff = cash_account_state();
677        after_cutoff.ts_event = UnixNanos::from(200_000_000_001);
678        account.base_apply(after_cutoff);
679
680        account.base_purge_account_events(UnixNanos::from(300_000_000_000), 100);
681
682        assert_eq!(account.events.len(), 1);
683        assert_eq!(account.events[0].ts_event, UnixNanos::from(200_000_000_001));
684    }
685
686    #[rstest]
687    fn test_base_purge_account_events_retains_future_event_without_overflow() {
688        let mut event = cash_account_state();
689        event.ts_event = UnixNanos::from(u64::MAX - 1);
690        let mut account = BaseAccount::new(event, true);
691
692        account.base_purge_account_events(UnixNanos::from(u64::MAX), 60);
693
694        assert_eq!(account.events.len(), 1);
695    }
696
697    #[rstest]
698    #[should_panic(
699        expected = r#"lhs_param: "event.account_id", rhs_param: "self.id", lhs: "OTHER-001", rhs: "SIM-001""#
700    )]
701    fn test_base_apply_panics_on_different_account_id() {
702        let mut account = BaseAccount::new(cash_account_state(), true);
703        let mut event = cash_account_state();
704        event.account_id = AccountId::from("OTHER-001");
705
706        account.base_apply(event);
707    }
708
709    fn usd_balances(total: &str) -> IndexMap<Currency, AccountBalance> {
710        let total = Money::from(total);
711        let mut balances = IndexMap::new();
712        balances.insert(
713            Currency::USD(),
714            AccountBalance::new(total, Money::zero(Currency::USD()), total),
715        );
716        balances
717    }
718
719    fn mismatched_usd() -> Currency {
720        Currency::new(
721            "USD",
722            Currency::USD().precision + 1,
723            840,
724            "United States dollar",
725            crate::enums::CurrencyType::Fiat,
726        )
727    }
728
729    // The observed case also exercises `balance_from_locks`; the unobserved case reaches the
730    // early return, so only the guard in `update_balance_locked` can reject it.
731    #[rstest]
732    #[case::observed_currency(true)]
733    #[case::unobserved_currency(false)]
734    fn test_update_balance_locked_rejects_negative_without_mutation(#[case] observed: bool) {
735        let mut balances = if observed {
736            usd_balances("1000 USD")
737        } else {
738            IndexMap::new()
739        };
740        let balances_before = balances.clone();
741        let mut balances_locked = AHashMap::new();
742        let instrument_id = InstrumentId::from("AUD/USD.SIM");
743
744        let error = update_balance_locked(
745            &mut balances,
746            &mut balances_locked,
747            instrument_id,
748            Money::from("-1 USD"),
749        )
750        .unwrap_err();
751
752        assert_eq!(error.to_string(), "locked balance was negative: -1.00 USD");
753        assert!(balances_locked.is_empty());
754        assert_eq!(balances, balances_before);
755    }
756
757    #[rstest]
758    fn test_update_balance_locked_restores_prior_reservation_on_failure() {
759        let usd = Currency::USD();
760        let mut balances = usd_balances("1000 USD");
761        let stale_key = (InstrumentId::from("EUR/USD.SIM"), mismatched_usd());
762        let stale = Money::from_decimal(Decimal::from(10), mismatched_usd()).unwrap();
763        let mut balances_locked = AHashMap::from([(stale_key, stale)]);
764        let instrument_id = InstrumentId::from("AUD/USD.SIM");
765
766        // A reservation that is valid on its own, but cannot derive a balance alongside the
767        // stale entry already recorded for this currency
768        let result = update_balance_locked(
769            &mut balances,
770            &mut balances_locked,
771            instrument_id,
772            Money::from("100 USD"),
773        );
774
775        assert!(result.is_err());
776        assert_eq!(balances_locked, AHashMap::from([(stale_key, stale)]));
777        assert_eq!(balances, usd_balances("1000 USD"));
778        assert_eq!(balances[&usd].free, Money::from("1000 USD"));
779    }
780
781    #[rstest]
782    fn test_balance_from_locks_clamps_reservations_to_total() {
783        let usd = Currency::USD();
784        let total = Money::from("100 USD");
785        let current = AccountBalance::new(total, Money::zero(usd), total);
786        let mut balances_locked = AHashMap::new();
787        balances_locked.insert(
788            (InstrumentId::from("AUD/USD.SIM"), usd),
789            Money::from("60 USD"),
790        );
791        balances_locked.insert(
792            (InstrumentId::from("EUR/USD.SIM"), usd),
793            Money::from("60 USD"),
794        );
795
796        let balance = balance_from_locks(current, &balances_locked).unwrap();
797
798        assert_eq!(balance.total, total);
799        assert_eq!(balance.locked, total);
800        assert_eq!(balance.free, Money::zero(usd));
801    }
802
803    #[rstest]
804    #[case::positive_total("1000 USD", "1000 USD", "0 USD")]
805    #[case::negative_total("-1000 USD", "0 USD", "-1000 USD")]
806    fn test_recalculate_balance_degrades_to_non_spendable_for_invalid_reservation(
807        #[case] total: &str,
808        #[case] expected_locked: &str,
809        #[case] expected_free: &str,
810    ) {
811        use crate::{enums::CurrencyType, types::Currency};
812
813        let usd = Currency::USD();
814        let total = Money::from(total);
815        let mut balances = IndexMap::new();
816        balances.insert(usd, AccountBalance::new(total, Money::zero(usd), total));
817        // A reservation at a differing precision cannot derive a valid balance
818        let mismatched_usd = Currency::new(
819            "USD",
820            usd.precision + 1,
821            840,
822            "United States dollar",
823            CurrencyType::Fiat,
824        );
825        let mut balances_locked = AHashMap::new();
826        balances_locked.insert(
827            (InstrumentId::from("AUD/USD.SIM"), mismatched_usd),
828            Money::from_decimal(Decimal::from(100), mismatched_usd).unwrap(),
829        );
830
831        recalculate_balance(&mut balances, &balances_locked, usd);
832
833        let balance = balances.get(&usd).expect("balance should be retained");
834        assert_eq!(balance.total, total);
835        assert_eq!(balance.locked, Money::from(expected_locked));
836        assert_eq!(balance.free, Money::from(expected_free));
837    }
838
839    #[rstest]
840    fn test_update_commissions_sub_canonical_raw_skipped() {
841        use crate::{
842            events::account::stubs::cash_account_state,
843            types::{Currency, Money},
844        };
845
846        let mut account = BaseAccount::new(cash_account_state(), true);
847        let usd = Currency::USD();
848
849        // Sub-canonical raw (1 < tick size for USD precision 2) normalizes to zero
850        account.update_commissions(Money::from_raw(1, usd));
851
852        assert!(account.commission(&usd).is_none());
853    }
854
855    #[rstest]
856    fn test_commissions_returns_every_currency() {
857        let mut account = BaseAccount::new(cash_account_state(), true);
858        account.update_commissions(Money::from("2.50 USD"));
859        account.update_commissions(Money::from("1.25 AUD"));
860
861        let commissions = account.commissions();
862
863        assert_eq!(commissions.len(), 2);
864        assert_eq!(
865            commissions.get(&Currency::USD()),
866            Some(&Money::from("2.50 USD"))
867        );
868        assert_eq!(
869            commissions.get(&Currency::AUD()),
870            Some(&Money::from("1.25 AUD"))
871        );
872    }
873
874    #[rstest]
875    fn test_try_update_commissions_overflow_preserves_total() {
876        let mut account = BaseAccount::new(cash_account_state(), true);
877        let usd = Currency::USD();
878        let maximum = Money::from_raw(MONEY_RAW_MAX, usd);
879
880        account.try_update_commissions(maximum).unwrap();
881        let result = account.try_update_commissions(Money::from("0.01 USD"));
882
883        assert!(result.is_err());
884        assert_eq!(account.commission(&usd), Some(maximum));
885    }
886}