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 rust_decimal::Decimal;
28use serde::{Deserialize, Serialize};
29
30use crate::{
31    enums::{AccountType, LiquiditySide, OrderSide},
32    events::{AccountState, OrderFilled},
33    fees::{MakerTakerFeeRates, calculate_maker_taker_commission},
34    identifiers::{AccountId, InstrumentId},
35    instruments::{Instrument, InstrumentAny},
36    position::Position,
37    types::{AccountBalance, Currency, Money, Price, Quantity},
38};
39
40/// 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        // TODO: Remove once from_raw enforces canonical precision alignment (v2)
204        let commission = commission.normalized();
205        if commission.is_zero() {
206            return Ok(());
207        }
208        let currency = commission.currency;
209        let total = self
210            .commissions
211            .get(&currency)
212            .copied()
213            .map_or(Some(commission), |total| total.checked_add(commission))
214            .ok_or_else(|| anyhow::anyhow!("{currency} commission total exceeds Money bounds"))?;
215        self.commissions.insert(currency, total);
216        Ok(())
217    }
218
219    /// Returns the total commission for the specified currency.
220    #[must_use]
221    pub fn commission(&self, currency: &Currency) -> Option<Money> {
222        self.commissions.get(currency).copied()
223    }
224
225    /// Returns a map of all commissions by currency.
226    #[must_use]
227    pub fn commissions(&self) -> AHashMap<Currency, Money> {
228        self.commissions.clone()
229    }
230
231    /// Checks the event belongs to this account.
232    ///
233    /// Concrete accounts call this before mutating any state, so a foreign event is rejected
234    /// rather than partially applied.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if `event.account_id` does not match this account's ID.
239    pub(crate) fn check_event_account_id(&self, event: &AccountState) -> anyhow::Result<()> {
240        anyhow::ensure!(
241            event.account_id == self.id,
242            "Account event had a different account ID: expected {}, received {}",
243            self.id,
244            event.account_id
245        );
246        Ok(())
247    }
248
249    /// Applies an [`AccountState`] event, updating balances.
250    ///
251    /// # Panics
252    ///
253    /// Panics if `event.account_id` does not match this account's ID. Every account rejects a
254    /// foreign event before reaching here, so this remains an internal invariant.
255    pub fn base_apply(&mut self, event: AccountState) {
256        check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
257        self.update_balances(&event.balances);
258        self.events.push(event);
259    }
260
261    /// Purges all account state events which are outside the lookback window.
262    ///
263    /// Guaranteed to retain at least the latest event.
264    ///
265    /// # Panics
266    ///
267    /// Panics if the purging implementation is changed and all events are purged.
268    pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
269        let Ok(lookback_ns) = DurationNanos::try_from_secs(lookback_secs) else {
270            log::warn!(
271                "Cannot purge account events: lookback_secs {lookback_secs} is not representable in `u64` nanoseconds"
272            );
273            return;
274        };
275        let purge_cutoff = ts_now.checked_sub(lookback_ns);
276
277        let mut retained_events = Vec::new();
278
279        for event in &self.events {
280            if purge_cutoff.is_none_or(|cutoff| event.ts_event > cutoff) {
281                retained_events.push(event.clone());
282            }
283        }
284
285        // Guarantee ≥ 1 event
286        if retained_events.is_empty() && !self.events.is_empty() {
287            retained_events.push(self.events.last().expect("events not empty").clone());
288        }
289
290        self.events = retained_events;
291    }
292
293    /// Calculates the amount of balance to lock for a new order based on the given side, quantity, and price.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if the locked amount cannot be represented in the target currency.
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        let (amount, currency) = 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 = instrument.try_calculate_notional_value(
312                    quantity,
313                    price,
314                    use_quote_for_inverse,
315                )?;
316                (notional.as_decimal().max(Decimal::ZERO), notional.currency)
317            }
318            // No modeled base asset means nothing to reserve; inventory is venue-enforced.
319            OrderSide::Sell => match instrument.base_currency() {
320                Some(base_currency) => (quantity.as_decimal(), base_currency),
321                None => (Decimal::ZERO, instrument.quote_currency()),
322            },
323        };
324
325        Ok(Money::from_decimal(amount, currency)?)
326    }
327
328    /// Calculates profit and loss amounts for a filled order.
329    ///
330    /// For cash accounts, this calculates the balance impact of a fill:
331    /// - BUY: gain base currency quantity, lose quote currency notional.
332    /// - SELL: lose base currency quantity, gain quote currency notional.
333    ///
334    /// Note: Unlike betting accounts, cash accounts do NOT cap to position quantity.
335    /// The full fill quantity is used for PnL calculation.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if a PnL amount cannot be represented in the target currency.
340    pub fn base_calculate_pnls(
341        &self,
342        instrument: &InstrumentAny,
343        fill: &OrderFilled,
344        _position: Option<Position>,
345    ) -> anyhow::Result<Vec<Money>> {
346        let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
347        let base_currency = instrument.base_currency();
348
349        // No quantity capping (betting accounts cap to position qty, cash accounts don't)
350        let fill_qty = fill.last_qty;
351        let notional = instrument.try_calculate_notional_value(fill_qty, fill.last_px, None)?;
352
353        if fill.order_side == OrderSide::Buy {
354            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
355                pnls.insert(
356                    base_currency_value,
357                    Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
358                );
359            }
360            pnls.insert(notional.currency, -notional);
361        } else {
362            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
363                pnls.insert(
364                    base_currency_value,
365                    -Money::from_decimal(fill_qty.as_decimal(), base_currency_value)?,
366                );
367            }
368            pnls.insert(notional.currency, notional);
369        }
370        Ok(pnls.into_values().collect())
371    }
372
373    /// Calculates commission fees for a filled order from explicitly resolved fee rates.
374    ///
375    /// Fee policy belongs to the account-owned schedule, not the instrument. Callers must
376    /// resolve `fee_rates` for the instrument (including any exact override) before calling.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if `liquidity_side` is invalid, the notional value cannot be calculated,
381    /// or the commission cannot be represented in the target currency.
382    pub fn base_calculate_commission(
383        &self,
384        instrument: &InstrumentAny,
385        last_qty: Quantity,
386        last_px: Price,
387        liquidity_side: LiquiditySide,
388        fee_rates: MakerTakerFeeRates,
389        use_quote_for_inverse: Option<bool>,
390    ) -> anyhow::Result<Money> {
391        let rate = fee_rates.rate_for(liquidity_side)?;
392        calculate_maker_taker_commission(instrument, last_qty, last_px, rate, use_quote_for_inverse)
393    }
394}
395
396/// Updates the locked balance for the given instrument and currency, then recalculates the
397/// account balance for that currency from all per-(instrument, currency) locks.
398///
399/// The reservation is recorded without a balance when the currency has no observed balance yet,
400/// so a later balance report derives from it.
401///
402/// # Errors
403///
404/// Returns an error if `locked` is negative, its precision differs from the balance precision,
405/// or the reservations cannot produce a valid balance. Balances and reservations are left
406/// unchanged when an error is returned.
407pub(crate) fn update_balance_locked(
408    balances: &mut IndexMap<Currency, AccountBalance>,
409    balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
410    instrument_id: InstrumentId,
411    locked: Money,
412) -> anyhow::Result<()> {
413    anyhow::ensure!(
414        !locked.is_negative(),
415        "locked balance was negative: {locked}"
416    );
417
418    let currency = locked.currency;
419    let key = (instrument_id, currency);
420
421    let Some(current_balance) = balances.get(&currency).copied() else {
422        balances_locked.insert(key, locked);
423        return Ok(());
424    };
425
426    anyhow::ensure!(
427        current_balance.currency.precision == currency.precision,
428        "Cannot update {currency} reservation: precision {} differed from balance precision {}",
429        currency.precision,
430        current_balance.currency.precision
431    );
432
433    let previous = balances_locked.insert(key, locked);
434
435    match balance_from_locks(current_balance, balances_locked) {
436        Ok(balance) => {
437            balances.insert(currency, balance);
438            Ok(())
439        }
440        Err(e) => {
441            // Restore the prior reservation so a rejected update leaves nothing behind
442            match previous {
443                Some(previous) => balances_locked.insert(key, previous),
444                None => balances_locked.remove(&key),
445            };
446            Err(e.into())
447        }
448    }
449}
450
451/// Clears all locked balances for the given instrument ID, recalculating each affected currency.
452pub(crate) fn clear_balance_locked(
453    balances: &mut IndexMap<Currency, AccountBalance>,
454    balances_locked: &mut AHashMap<(InstrumentId, Currency), Money>,
455    instrument_id: InstrumentId,
456) {
457    let currencies_to_recalc: Vec<Currency> = balances_locked
458        .keys()
459        .filter(|(id, _)| *id == instrument_id)
460        .map(|(_, currency)| *currency)
461        .collect();
462
463    for currency in &currencies_to_recalc {
464        balances_locked.remove(&(instrument_id, *currency));
465    }
466
467    for currency in currencies_to_recalc {
468        recalculate_balance(balances, balances_locked, currency);
469    }
470}
471
472/// Recalculates the account balance for the specified currency based on per-instrument locks.
473///
474/// Sums all per-instrument locked amounts for the currency and updates the balance.
475/// If the total locked exceeds the total balance, clamps to total (free = 0).
476pub(crate) fn recalculate_balance(
477    balances: &mut IndexMap<Currency, AccountBalance>,
478    balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
479    currency: Currency,
480) {
481    let current_balance = if let Some(balance) = balances.get(&currency) {
482        *balance
483    } else {
484        log::debug!("Cannot recalculate balance when no current balance for {currency}");
485        return;
486    };
487
488    let new_balance = match balance_from_locks(current_balance, balances_locked) {
489        Ok(balance) => balance,
490        Err(e) => {
491            log::error!(
492                "Cannot recalculate {currency} balance from reservations: {e}; using a non-spendable balance"
493            );
494            non_spendable_balance(current_balance)
495        }
496    };
497
498    balances.insert(currency, new_balance);
499}
500
501/// Derives an account balance from its total and all local reservations for its currency.
502///
503/// # Errors
504///
505/// Returns an error if a reservation is negative, uses a different fixed precision, or the
506/// derived locked or free balance exceeds [`Money`] bounds.
507pub(crate) fn balance_from_locks(
508    current_balance: AccountBalance,
509    balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
510) -> CorrectnessResult<AccountBalance> {
511    let currency = current_balance.currency;
512    let mut locked_total = Money::zero(currency);
513
514    for locked in balances_locked
515        .values()
516        .filter(|locked| locked.currency == currency)
517    {
518        if locked.is_negative() {
519            return Err(CorrectnessError::PredicateViolation {
520                message: format!("locked balance was negative: {locked}"),
521            });
522        }
523
524        if locked.currency.precision != currency.precision {
525            return Err(CorrectnessError::PredicateViolation {
526                message: format!(
527                    "locked balance precision {} differed from balance precision {} for {currency}",
528                    locked.currency.precision, currency.precision
529                ),
530            });
531        }
532
533        let reservation = if current_balance.total.is_negative() {
534            *locked
535        } else {
536            (*locked).min(current_balance.total - locked_total)
537        };
538
539        locked_total = locked_total.checked_add(reservation).ok_or_else(|| {
540            CorrectnessError::PredicateViolation {
541                message: format!("derived locked balance exceeded Money bounds for {currency}"),
542            }
543        })?;
544    }
545
546    let free = current_balance
547        .total
548        .checked_sub(locked_total)
549        .ok_or_else(|| CorrectnessError::PredicateViolation {
550            message: format!(
551                "derived free balance exceeded Money bounds for total {} and locked {locked_total}",
552                current_balance.total
553            ),
554        })?;
555
556    AccountBalance::new_checked(current_balance.total, locked_total, free)
557}
558
559fn non_spendable_balance(current_balance: AccountBalance) -> AccountBalance {
560    let zero = Money::zero(current_balance.currency);
561
562    let (locked, free) = if current_balance.total.is_negative() {
563        (zero, current_balance.total)
564    } else {
565        (current_balance.total, zero)
566    };
567
568    AccountBalance {
569        currency: current_balance.currency,
570        total: current_balance.total,
571        locked,
572        free,
573    }
574}
575
576#[cfg(all(test, feature = "test-support"))]
577mod tests {
578    use rstest::rstest;
579
580    use super::*;
581    use crate::{events::account::stubs::cash_account_state, types::money::MONEY_RAW_MAX};
582
583    #[rstest]
584    fn test_base_purge_account_events_retains_latest_when_all_purged() {
585        use crate::{
586            enums::AccountType,
587            events::account::stubs::cash_account_state,
588            identifiers::stubs::{account_id, uuid4},
589            types::{Currency, stubs::stub_account_balance},
590        };
591
592        let mut account = BaseAccount::new(cash_account_state(), true);
593
594        // Create events with different timestamps manually
595        let event1 = AccountState::new(
596            account_id(),
597            AccountType::Cash,
598            vec![stub_account_balance()],
599            vec![],
600            true,
601            uuid4(),
602            UnixNanos::from(100_000_000),
603            UnixNanos::from(100_000_000),
604            Some(Currency::USD()),
605        );
606        let event2 = AccountState::new(
607            account_id(),
608            AccountType::Cash,
609            vec![stub_account_balance()],
610            vec![],
611            true,
612            uuid4(),
613            UnixNanos::from(200_000_000),
614            UnixNanos::from(200_000_000),
615            Some(Currency::USD()),
616        );
617        let event3 = AccountState::new(
618            account_id(),
619            AccountType::Cash,
620            vec![stub_account_balance()],
621            vec![],
622            true,
623            uuid4(),
624            UnixNanos::from(300_000_000),
625            UnixNanos::from(300_000_000),
626            Some(Currency::USD()),
627        );
628
629        account.base_apply(event1);
630        account.base_apply(event2);
631        account.base_apply(event3.clone());
632
633        assert_eq!(account.events.len(), 4);
634
635        account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
636
637        assert_eq!(account.events.len(), 1);
638        assert_eq!(account.events[0].ts_event, event3.ts_event);
639        assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
640    }
641
642    #[rstest]
643    fn test_base_purge_account_events_retains_all_for_overflowing_lookback() {
644        let mut account = BaseAccount::new(cash_account_state(), true);
645        let mut event = cash_account_state();
646        event.ts_event = UnixNanos::from(1);
647        account.base_apply(event);
648
649        account.base_purge_account_events(UnixNanos::from(u64::MAX), u64::MAX);
650
651        assert_eq!(account.events.len(), 2);
652    }
653
654    #[rstest]
655    fn test_base_purge_account_events_drops_event_exactly_at_cutoff() {
656        let mut account = BaseAccount::new(cash_account_state(), true);
657        let mut at_cutoff = cash_account_state();
658        at_cutoff.ts_event = UnixNanos::from(200_000_000_000);
659        account.base_apply(at_cutoff);
660        let mut after_cutoff = cash_account_state();
661        after_cutoff.ts_event = UnixNanos::from(200_000_000_001);
662        account.base_apply(after_cutoff);
663
664        account.base_purge_account_events(UnixNanos::from(300_000_000_000), 100);
665
666        assert_eq!(account.events.len(), 1);
667        assert_eq!(account.events[0].ts_event, UnixNanos::from(200_000_000_001));
668    }
669
670    #[rstest]
671    fn test_base_purge_account_events_retains_future_event_without_overflow() {
672        let mut event = cash_account_state();
673        event.ts_event = UnixNanos::from(u64::MAX - 1);
674        let mut account = BaseAccount::new(event, true);
675
676        account.base_purge_account_events(UnixNanos::from(u64::MAX), 60);
677
678        assert_eq!(account.events.len(), 1);
679    }
680
681    #[rstest]
682    #[should_panic(
683        expected = r#"lhs_param: "event.account_id", rhs_param: "self.id", lhs: "OTHER-001", rhs: "SIM-001""#
684    )]
685    fn test_base_apply_panics_on_different_account_id() {
686        let mut account = BaseAccount::new(cash_account_state(), true);
687        let mut event = cash_account_state();
688        event.account_id = AccountId::from("OTHER-001");
689
690        account.base_apply(event);
691    }
692
693    fn usd_balances(total: &str) -> IndexMap<Currency, AccountBalance> {
694        let total = Money::from(total);
695        let mut balances = IndexMap::new();
696        balances.insert(
697            Currency::USD(),
698            AccountBalance::new(total, Money::zero(Currency::USD()), total),
699        );
700        balances
701    }
702
703    fn mismatched_usd() -> Currency {
704        Currency::new(
705            "USD",
706            Currency::USD().precision + 1,
707            840,
708            "United States dollar",
709            crate::enums::CurrencyType::Fiat,
710        )
711    }
712
713    // The observed case also exercises `balance_from_locks`; the unobserved case reaches the
714    // early return, so only the guard in `update_balance_locked` can reject it.
715    #[rstest]
716    #[case::observed_currency(true)]
717    #[case::unobserved_currency(false)]
718    fn test_update_balance_locked_rejects_negative_without_mutation(#[case] observed: bool) {
719        let mut balances = if observed {
720            usd_balances("1000 USD")
721        } else {
722            IndexMap::new()
723        };
724        let balances_before = balances.clone();
725        let mut balances_locked = AHashMap::new();
726        let instrument_id = InstrumentId::from("AUD/USD.SIM");
727
728        let error = update_balance_locked(
729            &mut balances,
730            &mut balances_locked,
731            instrument_id,
732            Money::from("-1 USD"),
733        )
734        .unwrap_err();
735
736        assert_eq!(error.to_string(), "locked balance was negative: -1.00 USD");
737        assert!(balances_locked.is_empty());
738        assert_eq!(balances, balances_before);
739    }
740
741    #[rstest]
742    fn test_update_balance_locked_restores_prior_reservation_on_failure() {
743        let usd = Currency::USD();
744        let mut balances = usd_balances("1000 USD");
745        let stale_key = (InstrumentId::from("EUR/USD.SIM"), mismatched_usd());
746        let stale = Money::from_decimal(Decimal::from(10), mismatched_usd()).unwrap();
747        let mut balances_locked = AHashMap::from([(stale_key, stale)]);
748        let instrument_id = InstrumentId::from("AUD/USD.SIM");
749
750        // A reservation that is valid on its own, but cannot derive a balance alongside the
751        // stale entry already recorded for this currency
752        let result = update_balance_locked(
753            &mut balances,
754            &mut balances_locked,
755            instrument_id,
756            Money::from("100 USD"),
757        );
758
759        assert!(result.is_err());
760        assert_eq!(balances_locked, AHashMap::from([(stale_key, stale)]));
761        assert_eq!(balances, usd_balances("1000 USD"));
762        assert_eq!(balances[&usd].free, Money::from("1000 USD"));
763    }
764
765    #[rstest]
766    fn test_balance_from_locks_clamps_reservations_to_total() {
767        let usd = Currency::USD();
768        let total = Money::from("100 USD");
769        let current = AccountBalance::new(total, Money::zero(usd), total);
770        let mut balances_locked = AHashMap::new();
771        balances_locked.insert(
772            (InstrumentId::from("AUD/USD.SIM"), usd),
773            Money::from("60 USD"),
774        );
775        balances_locked.insert(
776            (InstrumentId::from("EUR/USD.SIM"), usd),
777            Money::from("60 USD"),
778        );
779
780        let balance = balance_from_locks(current, &balances_locked).unwrap();
781
782        assert_eq!(balance.total, total);
783        assert_eq!(balance.locked, total);
784        assert_eq!(balance.free, Money::zero(usd));
785    }
786
787    #[rstest]
788    #[case::positive_total("1000 USD", "1000 USD", "0 USD")]
789    #[case::negative_total("-1000 USD", "0 USD", "-1000 USD")]
790    fn test_recalculate_balance_degrades_to_non_spendable_for_invalid_reservation(
791        #[case] total: &str,
792        #[case] expected_locked: &str,
793        #[case] expected_free: &str,
794    ) {
795        use crate::{enums::CurrencyType, types::Currency};
796
797        let usd = Currency::USD();
798        let total = Money::from(total);
799        let mut balances = IndexMap::new();
800        balances.insert(usd, AccountBalance::new(total, Money::zero(usd), total));
801        // A reservation at a differing precision cannot derive a valid balance
802        let mismatched_usd = Currency::new(
803            "USD",
804            usd.precision + 1,
805            840,
806            "United States dollar",
807            CurrencyType::Fiat,
808        );
809        let mut balances_locked = AHashMap::new();
810        balances_locked.insert(
811            (InstrumentId::from("AUD/USD.SIM"), mismatched_usd),
812            Money::from_decimal(Decimal::from(100), mismatched_usd).unwrap(),
813        );
814
815        recalculate_balance(&mut balances, &balances_locked, usd);
816
817        let balance = balances.get(&usd).expect("balance should be retained");
818        assert_eq!(balance.total, total);
819        assert_eq!(balance.locked, Money::from(expected_locked));
820        assert_eq!(balance.free, Money::from(expected_free));
821    }
822
823    #[rstest]
824    fn test_update_commissions_sub_canonical_raw_skipped() {
825        use crate::{
826            events::account::stubs::cash_account_state,
827            types::{Currency, Money},
828        };
829
830        let mut account = BaseAccount::new(cash_account_state(), true);
831        let usd = Currency::USD();
832
833        // Sub-canonical raw (1 < tick size for USD precision 2) normalizes to zero
834        account.update_commissions(Money::from_raw(1, usd));
835
836        assert!(account.commission(&usd).is_none());
837    }
838
839    #[rstest]
840    fn test_commissions_returns_every_currency() {
841        let mut account = BaseAccount::new(cash_account_state(), true);
842        account.update_commissions(Money::from("2.50 USD"));
843        account.update_commissions(Money::from("1.25 AUD"));
844
845        let commissions = account.commissions();
846
847        assert_eq!(commissions.len(), 2);
848        assert_eq!(
849            commissions.get(&Currency::USD()),
850            Some(&Money::from("2.50 USD"))
851        );
852        assert_eq!(
853            commissions.get(&Currency::AUD()),
854            Some(&Money::from("1.25 AUD"))
855        );
856    }
857
858    #[rstest]
859    fn test_try_update_commissions_overflow_preserves_total() {
860        let mut account = BaseAccount::new(cash_account_state(), true);
861        let usd = Currency::USD();
862        let maximum = Money::from_raw(MONEY_RAW_MAX, usd);
863
864        account.try_update_commissions(maximum).unwrap();
865        let result = account.try_update_commissions(Money::from("0.01 USD"));
866
867        assert!(result.is_err());
868        assert_eq!(account.commission(&usd), Some(maximum));
869    }
870}