Skip to main content

nautilus_model/accounts/
wallet.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//! A blockchain wallet account holding unleveraged native and ERC-20 token balances.
17//!
18//! The account is multi-currency with no base currency, no margin entries, and no borrowing:
19//! every reported `total` is the observed on-chain balance and negative totals are rejected.
20//! ERC-20 allowances are spender authorizations and are not represented as balances or locked
21//! funds.
22//!
23//! # Balance locking
24//!
25//! Locked balances track local pending-order reservations per `(InstrumentId, Currency)`,
26//! without changing the reported on-chain totals: `free = total - locked`. Account state events
27//! contribute totals only; locked and free balances are always derived from local reservations.
28//! Rebuilding an instrument reservation clears its prior currency locks before applying the new
29//! exact set. Computed BUY notionals for non-inverse, non-quanto instruments round up to the
30//! observed currency's smallest unit so the reservation never understates the possible spend.
31//!
32//! # Graceful degradation
33//!
34//! When total locked exceeds total balance (e.g., due to on-chain state latency), the account
35//! clamps locked to total rather than raising an error. This yields zero free balance,
36//! preventing new orders while avoiding crashes in live trading.
37
38use std::{
39    fmt::Display,
40    ops::{Deref, DerefMut},
41};
42
43use ahash::{AHashMap, AHashSet};
44use nautilus_core::correctness::{
45    CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_predicate_false,
46    check_predicate_true,
47};
48use ruint::aliases::U512;
49use serde::{Deserialize, Deserializer, Serialize, de};
50
51use crate::{
52    accounts::{
53        Account,
54        base::{self, BaseAccount},
55    },
56    enums::{AccountType, OrderSide},
57    events::{AccountState, OrderFilled},
58    identifiers::InstrumentId,
59    instruments::{Instrument, InstrumentAny},
60    position::Position,
61    types::{
62        AccountBalance, Currency, Money, Price, Quantity,
63        fixed::{check_fixed_raw_i128, check_fixed_raw_u128, raw_scale},
64        money::MoneyRaw,
65    },
66};
67
68/// Represents a blockchain wallet account holding native and ERC-20 token balances.
69#[derive(Debug, Clone, Serialize)]
70#[cfg_attr(
71    feature = "python",
72    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
73)]
74#[cfg_attr(
75    feature = "python",
76    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
77)]
78pub struct WalletAccount {
79    /// The account state shared by every account type.
80    pub base: BaseAccount,
81    /// Per-(instrument, currency) locked balances (transient, not persisted).
82    #[serde(skip, default)]
83    pub balances_locked: AHashMap<(InstrumentId, Currency), Money>,
84}
85
86impl WalletAccount {
87    /// Creates a new [`WalletAccount`] instance.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the initial event is not a valid wallet account state.
92    pub fn new_checked(
93        mut event: AccountState,
94        calculate_account_state: bool,
95    ) -> CorrectnessResult<Self> {
96        Self::validate_event(&event)?;
97        event.balances = Self::normalize_balances(&event.balances)?;
98        Ok(Self {
99            base: BaseAccount::new(event, calculate_account_state),
100            balances_locked: AHashMap::new(),
101        })
102    }
103
104    /// Creates a new [`WalletAccount`] instance.
105    ///
106    /// # Panics
107    ///
108    /// Panics if the initial event is not a valid wallet account state.
109    #[must_use]
110    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
111        Self::new_checked(event, calculate_account_state).expect_display(FAILED)
112    }
113
114    #[must_use]
115    pub(crate) fn clone_without_events(&self) -> Self {
116        Self {
117            base: self.base.clone_without_events(),
118            balances_locked: self.balances_locked.clone(),
119        }
120    }
121
122    /// Updates the locked balance for the given instrument and currency.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if `locked` is negative, the wallet has no observed balance for its
127    /// currency, or the local reservations cannot produce a valid balance.
128    pub fn update_balance_locked(
129        &mut self,
130        instrument_id: InstrumentId,
131        locked: Money,
132    ) -> anyhow::Result<()> {
133        let current_balance = self
134            .base
135            .balances
136            .get(&locked.currency)
137            .copied()
138            .ok_or_else(|| {
139                anyhow::anyhow!("wallet has no observed balance for {}", locked.currency)
140            })?;
141        Self::validate_observed_balance(current_balance)?;
142        let locked = Self::normalize_reservation(locked, current_balance.currency)?;
143        let key = (instrument_id, current_balance.currency);
144        let previous = self.balances_locked.remove_entry(&key);
145        self.balances_locked.insert(key, locked);
146        let balance = match Self::balance_from_locks_checked(current_balance, &self.balances_locked)
147        {
148            Ok(balance) => balance,
149            Err(e) => {
150                self.balances_locked.remove(&key);
151                if let Some((previous_key, previous)) = previous {
152                    self.balances_locked.insert(previous_key, previous);
153                }
154
155                return Err(e.into());
156            }
157        };
158        self.base.balances.insert(current_balance.currency, balance);
159
160        Ok(())
161    }
162
163    /// Clears all locked balances for the given instrument ID.
164    pub fn clear_balance_locked(&mut self, instrument_id: InstrumentId) {
165        let currencies = self
166            .balances_locked
167            .iter()
168            .filter(|((id, _), _)| *id == instrument_id)
169            .flat_map(|((_, key_currency), locked)| [*key_currency, locked.currency])
170            .collect::<AHashSet<_>>();
171        let mut balances_locked = self.balances_locked.clone();
172        balances_locked.retain(|(id, _), _| *id != instrument_id);
173        let mut balances = self.base.balances.clone();
174
175        for currency in currencies {
176            let Some(current_balance) = balances.get(&currency).copied() else {
177                log::error!("Cannot clear wallet reservations: no observed balance for {currency}");
178                return;
179            };
180            let balance = match Self::balance_from_locks_checked(current_balance, &balances_locked)
181            {
182                Ok(balance) => balance,
183                Err(e) => {
184                    log::error!("Cannot clear wallet reservations for {currency}: {e}");
185                    return;
186                }
187            };
188            balances.insert(current_balance.currency, balance);
189        }
190
191        self.base.balances = balances;
192        self.balances_locked = balances_locked;
193    }
194
195    /// Updates the account balances, rejecting negative totals.
196    ///
197    /// A wallet balance is an observed on-chain amount and cannot become negative.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if any balance has a negative total, currencies are duplicated, or the
202    /// local reservations cannot produce valid balances.
203    pub fn update_balances(&mut self, balances: &[AccountBalance]) -> anyhow::Result<()> {
204        let balances = Self::normalize_balances(balances)?
205            .into_iter()
206            .map(|balance| Self::balance_from_locks_checked(balance, &self.balances_locked))
207            .collect::<CorrectnessResult<Vec<_>>>()?;
208        self.base.update_balances(&balances);
209
210        Ok(())
211    }
212
213    #[must_use]
214    pub const fn is_unleveraged(&self) -> bool {
215        true
216    }
217
218    /// Recalculates the account balance for the specified currency based on per-instrument locks.
219    ///
220    /// Sums all per-instrument locked amounts for the currency and updates the balance.
221    /// If the total locked exceeds the total balance, clamps to total (free = 0).
222    pub fn recalculate_balance(&mut self, currency: Currency) {
223        let Some(current_balance) = self.base.balances.get(&currency).copied() else {
224            log::debug!("Cannot recalculate balance when no current balance for {currency}");
225            return;
226        };
227
228        match Self::balance_from_locks_checked(current_balance, &self.balances_locked) {
229            Ok(balance) => {
230                self.base.balances.insert(current_balance.currency, balance);
231            }
232            Err(e) => {
233                log::error!("Cannot recalculate {currency} balance from reservations: {e}");
234            }
235        }
236    }
237
238    fn validate_event(event: &AccountState) -> CorrectnessResult<()> {
239        check_predicate_true(
240            event.account_type == AccountType::Wallet,
241            "Wallet account event had a non-wallet account type",
242        )?;
243        check_predicate_true(
244            event.base_currency.is_none(),
245            "Wallet account event had a base currency",
246        )?;
247        check_predicate_true(
248            event.margins.is_empty(),
249            "Wallet account event had margin balances",
250        )?;
251        Ok(())
252    }
253
254    fn normalize_balances(balances: &[AccountBalance]) -> CorrectnessResult<Vec<AccountBalance>> {
255        let mut currencies = AHashSet::new();
256
257        balances
258            .iter()
259            .map(|balance| {
260                if !currencies.insert(balance.currency) {
261                    return Err(CorrectnessError::PredicateViolation {
262                        message: format!(
263                            "Wallet account balances had duplicate currency {}",
264                            balance.currency
265                        ),
266                    });
267                }
268
269                check_predicate_false(
270                    balance.total.is_negative(),
271                    "Wallet account balance total was negative",
272                )?;
273                Self::validate_observed_balance(*balance)?;
274                AccountBalance::new_checked(
275                    balance.total,
276                    Money::zero(balance.currency),
277                    balance.total,
278                )
279            })
280            .collect()
281    }
282
283    fn validate_observed_balance(balance: AccountBalance) -> CorrectnessResult<()> {
284        if !(balance.currency == balance.total.currency
285            && balance.currency.precision == balance.total.currency.precision)
286        {
287            return Err(CorrectnessError::PredicateViolation {
288                message: format!(
289                    "Wallet account balance currency {} precision {} differed from total currency {} precision {}",
290                    balance.currency,
291                    balance.currency.precision,
292                    balance.total.currency,
293                    balance.total.currency.precision,
294                ),
295            });
296        }
297
298        Self::validate_money(balance.total)
299    }
300
301    #[allow(
302        clippy::useless_conversion,
303        reason = "the raw width differs when high-precision is disabled"
304    )]
305    fn validate_money(money: Money) -> CorrectnessResult<()> {
306        Money::from_raw_checked(money.raw(), money.currency)?;
307        Self::validate_raw(i128::from(money.raw()), money.currency.precision)
308    }
309
310    fn validate_raw(raw: i128, precision: u8) -> CorrectnessResult<()> {
311        check_fixed_raw_i128(raw, precision).map_err(|e| CorrectnessError::PredicateViolation {
312            message: e.to_string(),
313        })
314    }
315
316    #[allow(
317        clippy::useless_conversion,
318        reason = "the raw width differs when high-precision is disabled"
319    )]
320    fn validate_quantity(quantity: Quantity) -> CorrectnessResult<()> {
321        check_predicate_false(quantity.is_undefined(), "quantity was undefined")?;
322        Quantity::from_raw_checked(quantity.raw(), quantity.precision)?;
323        check_fixed_raw_u128(u128::from(quantity.raw()), quantity.precision).map_err(|e| {
324            CorrectnessError::PredicateViolation {
325                message: e.to_string(),
326            }
327        })
328    }
329
330    #[allow(
331        clippy::useless_conversion,
332        reason = "the raw width differs when high-precision is disabled"
333    )]
334    fn validate_price(price: Price) -> CorrectnessResult<()> {
335        check_predicate_true(price.is_positive(), "price was not positive")?;
336        Price::from_raw_checked(price.raw(), price.precision)?;
337        check_fixed_raw_i128(i128::from(price.raw()), price.precision).map_err(|e| {
338            CorrectnessError::PredicateViolation {
339                message: e.to_string(),
340            }
341        })
342    }
343
344    #[allow(
345        clippy::useless_conversion,
346        reason = "the raw width differs when high-precision is disabled"
347    )]
348    fn normalize_reservation(locked: Money, currency: Currency) -> CorrectnessResult<Money> {
349        if locked.is_negative() {
350            return Err(CorrectnessError::PredicateViolation {
351                message: format!("locked balance was negative: {locked}"),
352            });
353        }
354
355        Self::validate_money(locked)?;
356
357        Money::from_rescaled_raw(
358            i128::from(locked.raw()),
359            locked.currency.precision,
360            currency,
361            "wallet reservation",
362        )
363    }
364
365    #[allow(
366        clippy::useless_conversion,
367        reason = "the raw width differs when high-precision is disabled"
368    )]
369    fn calculate_notional_exact(
370        instrument: &InstrumentAny,
371        quantity: Quantity,
372        price: Price,
373        currency: Currency,
374    ) -> CorrectnessResult<Money> {
375        let multiplier = instrument.multiplier();
376        Self::validate_quantity(quantity)?;
377        Self::validate_quantity(multiplier)?;
378        Self::validate_price(price)?;
379
380        let quantity_raw = U512::from(quantity.raw());
381        let multiplier_raw = U512::from(multiplier.raw());
382
383        let price_raw = U512::from(u128::try_from(price.raw()).map_err(|_| {
384            CorrectnessError::PredicateViolation {
385                message: "price raw value was negative".to_string(),
386            }
387        })?);
388        let target_scale = U512::from(raw_scale(currency.precision));
389        let numerator = quantity_raw
390            .checked_mul(multiplier_raw)
391            .and_then(|value| value.checked_mul(price_raw))
392            .and_then(|value| value.checked_mul(target_scale))
393            .ok_or_else(|| CorrectnessError::PredicateViolation {
394                message: "wallet notional numerator overflowed".to_string(),
395            })?;
396        let denominator = U512::from(raw_scale(quantity.precision))
397            .checked_mul(U512::from(raw_scale(multiplier.precision)))
398            .and_then(|value| value.checked_mul(U512::from(raw_scale(price.precision))))
399            .ok_or_else(|| CorrectnessError::PredicateViolation {
400                message: "wallet notional denominator overflowed".to_string(),
401            })?;
402        let grid = raw_scale(currency.precision) / 10_u128.pow(u32::from(currency.precision));
403        let denominator = denominator.checked_mul(U512::from(grid)).ok_or_else(|| {
404            CorrectnessError::PredicateViolation {
405                message: "wallet notional grid denominator overflowed".to_string(),
406            }
407        })?;
408        let units = numerator / denominator;
409        let units = if (numerator % denominator).is_zero() {
410            units
411        } else {
412            units.checked_add(U512::from(1_u8)).ok_or_else(|| {
413                CorrectnessError::PredicateViolation {
414                    message: "wallet notional ceiling overflowed".to_string(),
415                }
416            })?
417        };
418        let raw = units.checked_mul(U512::from(grid)).ok_or_else(|| {
419            CorrectnessError::PredicateViolation {
420                message: "wallet notional raw value overflowed".to_string(),
421            }
422        })?;
423        let raw = u128::try_from(raw).map_err(|_| CorrectnessError::PredicateViolation {
424            message: format!("wallet notional for {currency} exceeds raw bounds"),
425        })?;
426        let raw: MoneyRaw = raw
427            .try_into()
428            .map_err(|_| CorrectnessError::PredicateViolation {
429                message: format!("wallet notional for {currency} exceeds Money raw bounds"),
430            })?;
431        Self::validate_raw(i128::from(raw), currency.precision)?;
432
433        Money::from_raw_checked(raw, currency)
434    }
435
436    fn balance_from_locks_checked(
437        current_balance: AccountBalance,
438        balances_locked: &AHashMap<(InstrumentId, Currency), Money>,
439    ) -> CorrectnessResult<AccountBalance> {
440        Self::validate_observed_balance(current_balance)?;
441        let currency = current_balance.currency;
442        let mut total_locked = Money::zero(currency);
443
444        for ((_, key_currency), locked) in balances_locked
445            .iter()
446            .filter(|((_, key), locked)| *key == currency || locked.currency == currency)
447        {
448            if !(*key_currency == locked.currency
449                && key_currency.precision == locked.currency.precision)
450            {
451                return Err(CorrectnessError::PredicateViolation {
452                    message: format!(
453                        "wallet reservation key currency {} precision {} differed from value currency {} precision {}",
454                        key_currency,
455                        key_currency.precision,
456                        locked.currency,
457                        locked.currency.precision,
458                    ),
459                });
460            }
461
462            if locked.currency.precision != currency.precision {
463                return Err(CorrectnessError::PredicateViolation {
464                    message: format!(
465                        "locked balance precision {} differed from balance precision {} for {currency}",
466                        locked.currency.precision, currency.precision
467                    ),
468                });
469            }
470
471            if locked.is_negative() {
472                return Err(CorrectnessError::PredicateViolation {
473                    message: format!("locked balance was negative: {locked}"),
474                });
475            }
476
477            Self::validate_money(*locked)?;
478            total_locked = total_locked.checked_add(*locked).ok_or_else(|| {
479                CorrectnessError::PredicateViolation {
480                    message: format!("{currency} wallet reservation total exceeds Money bounds"),
481                }
482            })?;
483        }
484
485        base::balance_from_locks(current_balance, balances_locked)
486    }
487
488    fn from_base_checked(mut base: BaseAccount) -> CorrectnessResult<Self> {
489        check_predicate_true(
490            base.account_type == AccountType::Wallet,
491            "Wallet account had a non-wallet account type",
492        )?;
493        check_predicate_true(
494            base.base_currency.is_none(),
495            "Wallet account had a base currency",
496        )?;
497        check_predicate_false(base.events.is_empty(), "Wallet account had no events")?;
498
499        for event in &base.events {
500            Self::validate_event(event)?;
501            Self::normalize_balances(&event.balances)?;
502            check_predicate_true(
503                event.account_id == base.id,
504                "Wallet account event had a different account ID",
505            )?;
506        }
507
508        for starting in base.balances_starting.values() {
509            check_predicate_false(
510                starting.is_negative(),
511                "Wallet account starting balance was negative",
512            )?;
513        }
514
515        let balances = base.balances.values().copied().collect::<Vec<_>>();
516        base.balances = Self::normalize_balances(&balances)?
517            .into_iter()
518            .map(|balance| (balance.currency, balance))
519            .collect();
520
521        Ok(Self {
522            base,
523            balances_locked: AHashMap::new(),
524        })
525    }
526}
527
528#[derive(Deserialize)]
529struct WalletAccountSerde {
530    base: BaseAccount,
531}
532
533impl<'de> Deserialize<'de> for WalletAccount {
534    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
535    where
536        D: Deserializer<'de>,
537    {
538        let account = WalletAccountSerde::deserialize(deserializer)?;
539        Self::from_base_checked(account.base).map_err(de::Error::custom)
540    }
541}
542
543impl Account for WalletAccount {
544    impl_account_base_members!();
545
546    fn is_cash_account(&self) -> bool {
547        self.account_type == AccountType::Cash
548    }
549
550    fn is_margin_account(&self) -> bool {
551        self.account_type == AccountType::Margin
552    }
553
554    fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
555        self.check_event_account_id(&event)?;
556        Self::validate_event(&event)?;
557        let mut event = event;
558        event.balances = Self::normalize_balances(&event.balances)?
559            .into_iter()
560            .map(|balance| Self::balance_from_locks_checked(balance, &self.balances_locked))
561            .collect::<CorrectnessResult<Vec<_>>>()?;
562        self.base_apply(event);
563
564        Ok(())
565    }
566
567    fn calculate_balance_locked(
568        &self,
569        instrument: &InstrumentAny,
570        side: OrderSide,
571        quantity: Quantity,
572        price: Price,
573        use_quote_for_inverse: Option<bool>,
574    ) -> anyhow::Result<Money> {
575        let base_currency = instrument
576            .base_currency()
577            .unwrap_or(instrument.quote_currency());
578        let source_currency = if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false)
579        {
580            base_currency
581        } else {
582            match side {
583                OrderSide::Buy if !instrument.is_inverse() => instrument.cost_currency(),
584                OrderSide::Buy => instrument.quote_currency(),
585                OrderSide::Sell => base_currency,
586            }
587        };
588        let current_balance = self
589            .base
590            .balances
591            .get(&source_currency)
592            .copied()
593            .ok_or_else(|| {
594                anyhow::anyhow!("wallet has no observed balance for {source_currency}")
595            })?;
596        Self::validate_observed_balance(current_balance)?;
597
598        if side == OrderSide::Sell {
599            return Money::from_quantity(quantity, current_balance.currency).map_err(Into::into);
600        }
601
602        Self::validate_quantity(quantity)?;
603        Self::validate_price(price)?;
604
605        if !instrument.is_inverse() && !instrument.is_quanto() {
606            return Self::calculate_notional_exact(
607                instrument,
608                quantity,
609                price,
610                current_balance.currency,
611            )
612            .map_err(Into::into);
613        }
614
615        let locked = self.base_calculate_balance_locked(
616            instrument,
617            side,
618            quantity,
619            price,
620            use_quote_for_inverse,
621        )?;
622        Self::normalize_reservation(locked, current_balance.currency).map_err(Into::into)
623    }
624
625    fn calculate_pnls(
626        &self,
627        instrument: &InstrumentAny,
628        fill: &OrderFilled,
629        position: Option<Position>,
630    ) -> anyhow::Result<Vec<Money>> {
631        self.base_calculate_pnls(instrument, fill, position)
632    }
633}
634
635impl Deref for WalletAccount {
636    type Target = BaseAccount;
637
638    fn deref(&self) -> &Self::Target {
639        &self.base
640    }
641}
642
643impl DerefMut for WalletAccount {
644    fn deref_mut(&mut self) -> &mut Self::Target {
645        &mut self.base
646    }
647}
648
649impl PartialEq for WalletAccount {
650    fn eq(&self, other: &Self) -> bool {
651        self.id == other.id
652    }
653}
654
655impl Eq for WalletAccount {}
656
657impl Display for WalletAccount {
658    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659        write!(
660            f,
661            "WalletAccount(id={}, type={}, base={})",
662            self.id,
663            self.account_type,
664            self.base_currency.map_or_else(
665                || "None".to_string(),
666                |base_currency| format!("{}", base_currency.code)
667            ),
668        )
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use ahash::AHashMap;
675    use indexmap::IndexMap;
676    use rstest::rstest;
677    use rust_decimal_macros::dec;
678
679    use crate::{
680        accounts::{Account, WalletAccount, stubs::*},
681        enums::{AccountType, LiquiditySide, OrderSide},
682        events::{AccountState, account::stubs::*},
683        fees::MakerTakerFeeRates,
684        identifiers::{AccountId, InstrumentId, stubs::uuid4},
685        instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, stubs::*},
686        orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
687        types::{
688            AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
689            money::{MONEY_RAW_MAX, MoneyRaw},
690        },
691    };
692    #[cfg(feature = "defi")]
693    use crate::{enums::CurrencyType, identifiers::Symbol, types::fixed::FIXED_PRECISION};
694
695    #[rstest]
696    fn test_display(wallet_account: WalletAccount) {
697        assert_eq!(
698            format!("{wallet_account}"),
699            "WalletAccount(id=SIM-001, type=WALLET, base=None)"
700        );
701    }
702
703    #[rstest]
704    fn test_instantiate_multi_currency_wallet_account(
705        wallet_account: WalletAccount,
706        wallet_account_state: AccountState,
707    ) {
708        assert_eq!(wallet_account.id, AccountId::from("SIM-001"));
709        assert_eq!(wallet_account.account_type, AccountType::Wallet);
710        assert_eq!(wallet_account.base_currency, None);
711        assert!(wallet_account.is_unleveraged());
712        assert!(!wallet_account.is_cash_account());
713        assert!(!wallet_account.is_margin_account());
714        assert_eq!(
715            wallet_account.last_event(),
716            Some(wallet_account_state.clone())
717        );
718        assert_eq!(wallet_account.events(), vec![wallet_account_state]);
719        assert_eq!(wallet_account.event_count(), 1);
720        assert_eq!(
721            wallet_account.balance_total(Some(Currency::ETH())),
722            Some(Money::from("10 ETH"))
723        );
724        assert_eq!(
725            wallet_account.balance_total(Some(Currency::USDC())),
726            Some(Money::from("25000 USDC"))
727        );
728        assert_eq!(
729            wallet_account.balance_free(Some(Currency::ETH())),
730            Some(Money::from("10 ETH"))
731        );
732        assert_eq!(
733            wallet_account.balance_locked(Some(Currency::USDC())),
734            Some(Money::from("0 USDC"))
735        );
736
737        let mut balances_total_expected = IndexMap::new();
738        balances_total_expected.insert(Currency::ETH(), Money::from("10 ETH"));
739        balances_total_expected.insert(Currency::USDC(), Money::from("25000 USDC"));
740        assert_eq!(wallet_account.balances_total(), balances_total_expected);
741
742        let mut starting_balances_expected = IndexMap::new();
743        starting_balances_expected.insert(Currency::ETH(), Money::from("10 ETH"));
744        starting_balances_expected.insert(Currency::USDC(), Money::from("25000 USDC"));
745        assert_eq!(
746            wallet_account.starting_balances(),
747            starting_balances_expected
748        );
749    }
750
751    #[rstest]
752    fn test_apply_given_new_state_event_updates_correctly(
753        mut wallet_account: WalletAccount,
754        wallet_account_state: AccountState,
755        wallet_account_state_changed: AccountState,
756    ) {
757        wallet_account
758            .apply(wallet_account_state_changed.clone())
759            .unwrap();
760
761        assert_eq!(
762            wallet_account.last_event(),
763            Some(wallet_account_state_changed.clone())
764        );
765        assert_eq!(
766            wallet_account.events,
767            vec![wallet_account_state, wallet_account_state_changed]
768        );
769        assert_eq!(wallet_account.event_count(), 2);
770        assert_eq!(
771            wallet_account.balance_total(Some(Currency::ETH())),
772            Some(Money::from("9.5 ETH"))
773        );
774        assert_eq!(
775            wallet_account.balance_locked(Some(Currency::ETH())),
776            Some(Money::from("0 ETH"))
777        );
778        assert_eq!(
779            wallet_account.balance_free(Some(Currency::ETH())),
780            Some(Money::from("9.5 ETH"))
781        );
782        assert_eq!(
783            wallet_account.balance_total(Some(Currency::USDC())),
784            Some(Money::from("30000 USDC"))
785        );
786    }
787
788    #[rstest]
789    fn test_apply_rejects_negative_balance(mut wallet_account: WalletAccount) {
790        let negative_state = AccountState::new(
791            AccountId::from("SIM-001"),
792            AccountType::Wallet,
793            vec![AccountBalance::new(
794                Money::from("-1 ETH"),
795                Money::from("0 ETH"),
796                Money::from("-1 ETH"),
797            )],
798            vec![],
799            false,
800            uuid4(),
801            0.into(),
802            0.into(),
803            None,
804        );
805
806        let result = wallet_account.apply(negative_state);
807        assert!(result.is_err());
808        assert_eq!(
809            result.unwrap_err().to_string(),
810            "Wallet account balance total was negative"
811        );
812    }
813
814    #[rstest]
815    fn test_apply_rejects_different_account_without_mutation(
816        mut wallet_account: WalletAccount,
817        currency_pair_btcusdt: CurrencyPair,
818        mut wallet_account_state_changed: AccountState,
819    ) {
820        wallet_account
821            .update_balance_locked(currency_pair_btcusdt.id, Money::from("2 ETH"))
822            .unwrap();
823        let events_before = wallet_account.events.clone();
824        let balances_before = wallet_account.balances.clone();
825        let locks_before = wallet_account.balances_locked.clone();
826        wallet_account_state_changed.account_id = AccountId::from("OTHER-001");
827
828        let result = wallet_account.apply(wallet_account_state_changed);
829
830        assert_eq!(
831            result.unwrap_err().to_string(),
832            "Account event had a different account ID: expected SIM-001, received OTHER-001"
833        );
834        assert_eq!(wallet_account.events, events_before);
835        assert_eq!(wallet_account.balances, balances_before);
836        assert_eq!(wallet_account.balances_locked, locks_before);
837    }
838
839    #[rstest]
840    fn test_apply_rejects_duplicate_currency_without_mutation(
841        mut wallet_account: WalletAccount,
842        mut wallet_account_state_changed: AccountState,
843    ) {
844        let events_before = wallet_account.events.clone();
845        let balances_before = wallet_account.balances.clone();
846        let duplicate = wallet_account_state_changed.balances[0];
847        wallet_account_state_changed.balances.push(duplicate);
848
849        let result = wallet_account.apply(wallet_account_state_changed);
850
851        assert_eq!(
852            result.unwrap_err().to_string(),
853            "Wallet account balances had duplicate currency ETH"
854        );
855        assert_eq!(wallet_account.events, events_before);
856        assert_eq!(wallet_account.balances, balances_before);
857    }
858
859    #[rstest]
860    fn test_apply_rejects_negative_local_lock_without_mutation(
861        mut wallet_account: WalletAccount,
862        currency_pair_btcusdt: CurrencyPair,
863        wallet_account_state_changed: AccountState,
864    ) {
865        wallet_account.balances_locked.insert(
866            (currency_pair_btcusdt.id, Currency::ETH()),
867            Money::from("-1 ETH"),
868        );
869        let events_before = wallet_account.events.clone();
870        let balances_before = wallet_account.balances.clone();
871        let locks_before = wallet_account.balances_locked.clone();
872
873        let result = wallet_account.apply(wallet_account_state_changed);
874
875        assert_eq!(
876            result.unwrap_err().to_string(),
877            "locked balance was negative: -1.00000000 ETH"
878        );
879        assert_eq!(wallet_account.events, events_before);
880        assert_eq!(wallet_account.balances, balances_before);
881        assert_eq!(wallet_account.balances_locked, locks_before);
882    }
883
884    #[rstest]
885    fn test_update_balances_rejects_negative_total(mut wallet_account: WalletAccount) {
886        let result = wallet_account.update_balances(&[AccountBalance::new(
887            Money::from("-10 USDC"),
888            Money::from("0 USDC"),
889            Money::from("-10 USDC"),
890        )]);
891
892        assert!(result.is_err());
893    }
894
895    #[rstest]
896    fn test_new_checked_rejects_negative_initial_balance() {
897        let negative_state = AccountState::new(
898            AccountId::from("SIM-001"),
899            AccountType::Wallet,
900            vec![AccountBalance::new(
901                Money::from("-1 ETH"),
902                Money::from("0 ETH"),
903                Money::from("-1 ETH"),
904            )],
905            vec![],
906            true,
907            uuid4(),
908            0.into(),
909            0.into(),
910            None,
911        );
912
913        let result = WalletAccount::new_checked(negative_state, true);
914
915        assert!(result.is_err());
916        assert_eq!(
917            result.unwrap_err().to_string(),
918            "Wallet account balance total was negative"
919        );
920    }
921
922    #[rstest]
923    fn test_update_balance_locked_reserves_without_changing_total(
924        mut wallet_account: WalletAccount,
925        currency_pair_btcusdt: CurrencyPair,
926    ) {
927        let instrument_id = currency_pair_btcusdt.id;
928
929        wallet_account
930            .update_balance_locked(instrument_id, Money::from("2 ETH"))
931            .unwrap();
932
933        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
934        assert_eq!(balance.total, Money::from("10 ETH"));
935        assert_eq!(balance.locked, Money::from("2 ETH"));
936        assert_eq!(balance.free, Money::from("8 ETH"));
937        assert_eq!(wallet_account.balances_locked.len(), 1);
938    }
939
940    #[rstest]
941    fn test_update_balance_locked_rejects_missing_observed_currency(
942        mut wallet_account: WalletAccount,
943        currency_pair_btcusdt: CurrencyPair,
944    ) {
945        let balances_before = wallet_account.base.balances.clone();
946        let locks_before = wallet_account.balances_locked.clone();
947        let events_before = wallet_account.events.clone();
948        let result =
949            wallet_account.update_balance_locked(currency_pair_btcusdt.id, Money::from("1 BTC"));
950
951        assert_eq!(
952            result.unwrap_err().to_string(),
953            "wallet has no observed balance for BTC"
954        );
955        assert_eq!(wallet_account.base.balances, balances_before);
956        assert_eq!(wallet_account.balances_locked, locks_before);
957        assert_eq!(wallet_account.events, events_before);
958    }
959
960    #[rstest]
961    fn test_update_balance_locked_multiple_currencies(
962        mut wallet_account: WalletAccount,
963        currency_pair_btcusdt: CurrencyPair,
964    ) {
965        let instrument_id = currency_pair_btcusdt.id;
966
967        wallet_account
968            .update_balance_locked(instrument_id, Money::from("2 ETH"))
969            .unwrap();
970        wallet_account
971            .update_balance_locked(instrument_id, Money::from("5000 USDC"))
972            .unwrap();
973
974        assert_eq!(wallet_account.balances_locked.len(), 2);
975        let eth_balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
976        assert_eq!(eth_balance.locked, Money::from("2 ETH"));
977        assert_eq!(eth_balance.free, Money::from("8 ETH"));
978        let usdc_balance = wallet_account.balance(Some(Currency::USDC())).unwrap();
979        assert_eq!(usdc_balance.total, Money::from("25000 USDC"));
980        assert_eq!(usdc_balance.locked, Money::from("5000 USDC"));
981        assert_eq!(usdc_balance.free, Money::from("20000 USDC"));
982    }
983
984    #[rstest]
985    fn test_clear_balance_locked_only_removes_target_instrument(mut wallet_account: WalletAccount) {
986        let weth_usdc_id = InstrumentId::from("WETHUSDC.BLOCKCHAIN");
987        let weth_dai_id = InstrumentId::from("WETHDAI.BLOCKCHAIN");
988
989        wallet_account
990            .update_balance_locked(weth_usdc_id, Money::from("2 ETH"))
991            .unwrap();
992        wallet_account
993            .update_balance_locked(weth_dai_id, Money::from("1 ETH"))
994            .unwrap();
995        assert_eq!(wallet_account.balances_locked.len(), 2);
996
997        wallet_account.clear_balance_locked(weth_usdc_id);
998
999        assert_eq!(wallet_account.balances_locked.len(), 1);
1000        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1001        assert_eq!(balance.total, Money::from("10 ETH"));
1002        assert_eq!(balance.locked, Money::from("1 ETH"));
1003        assert_eq!(balance.free, Money::from("9 ETH"));
1004    }
1005
1006    #[rstest]
1007    fn test_recalculate_balance_clamps_when_locked_exceeds_total(
1008        mut wallet_account: WalletAccount,
1009        currency_pair_btcusdt: CurrencyPair,
1010    ) {
1011        let instrument_id = currency_pair_btcusdt.id;
1012
1013        wallet_account
1014            .update_balance_locked(instrument_id, Money::from("15 ETH"))
1015            .unwrap();
1016
1017        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1018        assert_eq!(balance.total, Money::from("10 ETH"));
1019        assert_eq!(balance.locked, Money::from("10 ETH"));
1020        assert_eq!(balance.free, Money::from("0 ETH"));
1021    }
1022
1023    #[rstest]
1024    fn test_update_balance_locked_rejects_aggregate_overflow_without_mutation(
1025        mut wallet_account: WalletAccount,
1026    ) {
1027        let maximum = Money::from_raw(MONEY_RAW_MAX, Currency::ETH());
1028
1029        wallet_account
1030            .update_balance_locked(InstrumentId::from("WETHUSDC.BLOCKCHAIN"), maximum)
1031            .unwrap();
1032        let balances_before = wallet_account.base.balances.clone();
1033        let locks_before = wallet_account.balances_locked.clone();
1034        let events_before = wallet_account.events.clone();
1035
1036        let result =
1037            wallet_account.update_balance_locked(InstrumentId::from("WETHDAI.BLOCKCHAIN"), maximum);
1038
1039        assert_eq!(
1040            result.unwrap_err().to_string(),
1041            "ETH wallet reservation total exceeds Money bounds"
1042        );
1043        assert_eq!(wallet_account.base.balances, balances_before);
1044        assert_eq!(wallet_account.balances_locked, locks_before);
1045        assert_eq!(wallet_account.events, events_before);
1046    }
1047
1048    #[cfg(feature = "defi")]
1049    #[rstest]
1050    fn test_update_balance_locked_normalizes_to_observed_precision() {
1051        let observed = test_currency("TST", 18);
1052        let source = test_currency("TST", 16);
1053        let mut wallet = wallet_with_total(observed, 1_000_000_000_000_000_000);
1054        let instrument_id = InstrumentId::from("TSTUSDC.BLOCKCHAIN");
1055        let reservation = Money::from_raw(1_234_567_890_123_456, source);
1056
1057        wallet
1058            .update_balance_locked(instrument_id, reservation)
1059            .unwrap();
1060
1061        let stored = wallet
1062            .balances_locked
1063            .get(&(instrument_id, observed))
1064            .unwrap();
1065        let balance = wallet.balance(Some(observed)).unwrap();
1066        assert_eq!(stored.currency, observed);
1067        assert_eq!(stored.currency.precision, 18);
1068        assert_eq!(stored.raw(), 123_456_789_012_345_600);
1069        assert_eq!(balance.total.raw(), 1_000_000_000_000_000_000);
1070        assert_eq!(balance.locked.raw(), 123_456_789_012_345_600);
1071        assert_eq!(balance.free.raw(), 876_543_210_987_654_400);
1072    }
1073
1074    #[cfg(feature = "defi")]
1075    #[rstest]
1076    fn test_update_balance_locked_normalizes_to_observed_currency_grid() {
1077        let observed = test_currency("GRID", 6);
1078        let source = test_currency("GRID", FIXED_PRECISION);
1079        let scale = money_raw(10_i128.pow(u32::from(FIXED_PRECISION)));
1080        let grid = money_raw(10_i128.pow(u32::from(FIXED_PRECISION - observed.precision)));
1081        let reservation_raw = 123_456 * grid;
1082        let mut wallet = wallet_with_total(observed, scale);
1083        let instrument_id = InstrumentId::from("GRIDUSDC.BLOCKCHAIN");
1084        let reservation = Money::from_raw(reservation_raw, source);
1085
1086        wallet
1087            .update_balance_locked(instrument_id, reservation)
1088            .unwrap();
1089
1090        let stored = wallet
1091            .balances_locked
1092            .get(&(instrument_id, observed))
1093            .unwrap();
1094        let balance = wallet.balance(Some(observed)).unwrap();
1095        assert_eq!(stored.currency, observed);
1096        assert_eq!(stored.currency.precision, 6);
1097        assert_eq!(stored.raw(), reservation_raw);
1098        assert_eq!(balance.total.raw(), scale);
1099        assert_eq!(balance.locked.raw(), reservation_raw);
1100        assert_eq!(balance.free.raw(), scale - reservation_raw);
1101    }
1102
1103    #[cfg(feature = "defi")]
1104    #[rstest]
1105    fn test_update_balance_locked_rejects_observed_currency_grid_loss_without_mutation() {
1106        let observed = test_currency("GRID", 6);
1107        let source = test_currency("GRID", FIXED_PRECISION);
1108        let scale = money_raw(10_i128.pow(u32::from(FIXED_PRECISION)));
1109        let grid = money_raw(10_i128.pow(u32::from(FIXED_PRECISION - observed.precision)));
1110        let mut wallet = wallet_with_total(observed, scale);
1111        let balances_before = wallet.base.balances.clone();
1112        let locks_before = wallet.balances_locked.clone();
1113        let events_before = wallet.events.clone();
1114
1115        let result = wallet.update_balance_locked(
1116            InstrumentId::from("GRIDUSDC.BLOCKCHAIN"),
1117            Money::from_raw(123_456 * grid + 1, source),
1118        );
1119
1120        assert!(
1121            result
1122                .unwrap_err()
1123                .to_string()
1124                .contains("Invalid fixed-point raw value")
1125        );
1126        assert_eq!(wallet.base.balances, balances_before);
1127        assert_eq!(wallet.balances_locked, locks_before);
1128        assert_eq!(wallet.events, events_before);
1129    }
1130
1131    #[cfg(feature = "defi")]
1132    #[rstest]
1133    fn test_update_balance_locked_rejects_lossy_downscale_without_mutation() {
1134        let observed = test_currency("LOSS", 16);
1135        let source = test_currency("LOSS", 18);
1136        let mut wallet = wallet_with_total(observed, 10_000_000_000_000_000);
1137        let balances_before = wallet.base.balances.clone();
1138        let locks_before = wallet.balances_locked.clone();
1139        let events_before = wallet.events.clone();
1140
1141        let result = wallet.update_balance_locked(
1142            InstrumentId::from("LOSSUSDC.BLOCKCHAIN"),
1143            Money::from_raw(1, source),
1144        );
1145
1146        assert!(
1147            result
1148                .unwrap_err()
1149                .to_string()
1150                .contains("loses precision when decreasing raw scale")
1151        );
1152        assert_eq!(wallet.base.balances, balances_before);
1153        assert_eq!(wallet.balances_locked, locks_before);
1154        assert_eq!(wallet.events, events_before);
1155    }
1156
1157    #[cfg(feature = "defi")]
1158    #[rstest]
1159    fn test_update_balance_locked_rejects_non_canonical_raw_without_mutation() {
1160        let observed = test_currency("RAW", 18);
1161        let source = test_currency("RAW", 15);
1162        let mut wallet = wallet_with_total(observed, 10_000_000_000_000_000);
1163        let balances_before = wallet.base.balances.clone();
1164        let locks_before = wallet.balances_locked.clone();
1165        let events_before = wallet.events.clone();
1166
1167        let result = wallet.update_balance_locked(
1168            InstrumentId::from("RAWUSDC.BLOCKCHAIN"),
1169            Money::from_raw(1, source),
1170        );
1171
1172        assert!(
1173            result
1174                .unwrap_err()
1175                .to_string()
1176                .contains("Invalid fixed-point raw value")
1177        );
1178        assert_eq!(wallet.base.balances, balances_before);
1179        assert_eq!(wallet.balances_locked, locks_before);
1180        assert_eq!(wallet.events, events_before);
1181    }
1182
1183    #[cfg(feature = "defi")]
1184    #[rstest]
1185    fn test_update_balance_locked_rejects_scale_overflow_without_mutation() {
1186        let observed = test_currency("OVR", 18);
1187        let source = test_currency("OVR", 16);
1188        let mut wallet = wallet_with_total(observed, 10_000_000_000_000_000);
1189        let balances_before = wallet.base.balances.clone();
1190        let locks_before = wallet.balances_locked.clone();
1191        let events_before = wallet.events.clone();
1192
1193        let result = wallet.update_balance_locked(
1194            InstrumentId::from("OVRUSDC.BLOCKCHAIN"),
1195            Money::from_raw(MONEY_RAW_MAX, source),
1196        );
1197
1198        assert!(result.unwrap_err().to_string().contains("exceeded bounds"));
1199        assert_eq!(wallet.base.balances, balances_before);
1200        assert_eq!(wallet.balances_locked, locks_before);
1201        assert_eq!(wallet.events, events_before);
1202    }
1203
1204    #[rstest]
1205    fn test_update_balance_locked_rejects_negative_without_mutation() {
1206        let mut wallet = wallet_with_total(Currency::ETH(), 10_000_000_000_000_000);
1207        let balances_before = wallet.base.balances.clone();
1208        let locks_before = wallet.balances_locked.clone();
1209        let events_before = wallet.events.clone();
1210
1211        let result = wallet.update_balance_locked(
1212            InstrumentId::from("WETHUSDC.BLOCKCHAIN"),
1213            Money::from("-1 ETH"),
1214        );
1215
1216        assert!(result.unwrap_err().to_string().contains("was negative"));
1217        assert_eq!(wallet.base.balances, balances_before);
1218        assert_eq!(wallet.balances_locked, locks_before);
1219        assert_eq!(wallet.events, events_before);
1220    }
1221
1222    #[cfg(feature = "defi")]
1223    #[rstest]
1224    fn test_new_checked_rejects_non_canonical_observed_total() {
1225        let currency = test_currency("OBS", 15);
1226        let total = Money::from_raw(1, currency);
1227        let state = AccountState::new(
1228            AccountId::from("WALLET-OBS"),
1229            AccountType::Wallet,
1230            vec![AccountBalance::new(total, Money::zero(currency), total)],
1231            vec![],
1232            true,
1233            uuid4(),
1234            0.into(),
1235            0.into(),
1236            None,
1237        );
1238
1239        let result = WalletAccount::new_checked(state, true);
1240
1241        assert!(
1242            result
1243                .unwrap_err()
1244                .to_string()
1245                .contains("Invalid fixed-point raw value")
1246        );
1247    }
1248
1249    #[rstest]
1250    fn test_apply_reported_snapshot_preserves_locks(
1251        mut wallet_account: WalletAccount,
1252        currency_pair_btcusdt: CurrencyPair,
1253        mut wallet_account_state_changed: AccountState,
1254    ) {
1255        let instrument_id = currency_pair_btcusdt.id;
1256        wallet_account
1257            .update_balance_locked(instrument_id, Money::from("2 ETH"))
1258            .unwrap();
1259        wallet_account_state_changed.balances[0] = AccountBalance::new(
1260            Money::from("9.5 ETH"),
1261            Money::from("1 ETH"),
1262            Money::from("8.5 ETH"),
1263        );
1264
1265        wallet_account.apply(wallet_account_state_changed).unwrap();
1266
1267        assert_eq!(
1268            wallet_account
1269                .balances_locked
1270                .get(&(instrument_id, Currency::ETH(),)),
1271            Some(&Money::from("2 ETH"))
1272        );
1273        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1274        assert_eq!(balance.total, Money::from("9.5 ETH"));
1275        assert_eq!(balance.locked, Money::from("2 ETH"));
1276        assert_eq!(balance.free, Money::from("7.5 ETH"));
1277    }
1278
1279    #[rstest]
1280    fn test_apply_reported_empty_balances_preserves_locks(
1281        mut wallet_account: WalletAccount,
1282        currency_pair_btcusdt: CurrencyPair,
1283    ) {
1284        let instrument_id = currency_pair_btcusdt.id;
1285        wallet_account
1286            .update_balance_locked(instrument_id, Money::from("2 ETH"))
1287            .unwrap();
1288
1289        let empty_snapshot = AccountState::new(
1290            AccountId::from("SIM-001"),
1291            AccountType::Wallet,
1292            vec![],
1293            vec![],
1294            true,
1295            uuid4(),
1296            0.into(),
1297            0.into(),
1298            None,
1299        );
1300        wallet_account.apply(empty_snapshot).unwrap();
1301
1302        assert_eq!(wallet_account.balances_locked.len(), 1);
1303        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1304        assert_eq!(balance.locked, Money::from("2 ETH"));
1305        assert_eq!(balance.free, Money::from("8 ETH"));
1306    }
1307
1308    #[rstest]
1309    fn test_apply_partial_snapshot_preserves_omitted_currency_lock(
1310        mut wallet_account: WalletAccount,
1311    ) {
1312        let instrument_id = InstrumentId::from("WETHUSDC.BLOCKCHAIN");
1313        wallet_account
1314            .update_balance_locked(instrument_id, Money::from("5000 USDC"))
1315            .unwrap();
1316        let snapshot = AccountState::new(
1317            AccountId::from("SIM-001"),
1318            AccountType::Wallet,
1319            vec![AccountBalance::new(
1320                Money::from("9.5 ETH"),
1321                Money::from("0 ETH"),
1322                Money::from("9.5 ETH"),
1323            )],
1324            vec![],
1325            true,
1326            uuid4(),
1327            0.into(),
1328            0.into(),
1329            None,
1330        );
1331
1332        wallet_account.apply(snapshot).unwrap();
1333        wallet_account.clear_balance_locked(instrument_id);
1334
1335        let balance = wallet_account.balance(Some(Currency::USDC())).unwrap();
1336        assert_eq!(balance.total, Money::from("25000 USDC"));
1337        assert_eq!(balance.locked, Money::from("0 USDC"));
1338        assert_eq!(balance.free, Money::from("25000 USDC"));
1339    }
1340
1341    #[rstest]
1342    fn test_update_balances_rederives_existing_lock(
1343        mut wallet_account: WalletAccount,
1344        currency_pair_btcusdt: CurrencyPair,
1345    ) {
1346        let instrument_id = currency_pair_btcusdt.id;
1347        wallet_account
1348            .update_balance_locked(instrument_id, Money::from("2 ETH"))
1349            .unwrap();
1350
1351        wallet_account
1352            .update_balances(&[AccountBalance::new(
1353                Money::from("9 ETH"),
1354                Money::from("0 ETH"),
1355                Money::from("9 ETH"),
1356            )])
1357            .unwrap();
1358
1359        let balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1360        assert_eq!(balance.total, Money::from("9 ETH"));
1361        assert_eq!(balance.locked, Money::from("2 ETH"));
1362        assert_eq!(balance.free, Money::from("7 ETH"));
1363    }
1364
1365    #[rstest]
1366    fn test_apply_retains_requested_lock_across_total_recovery(
1367        mut wallet_account: WalletAccount,
1368        currency_pair_btcusdt: CurrencyPair,
1369    ) {
1370        let instrument_id = currency_pair_btcusdt.id;
1371        wallet_account
1372            .update_balance_locked(instrument_id, Money::from("8 ETH"))
1373            .unwrap();
1374        let reduced = AccountState::new(
1375            AccountId::from("SIM-001"),
1376            AccountType::Wallet,
1377            vec![AccountBalance::new(
1378                Money::from("5 ETH"),
1379                Money::from("0 ETH"),
1380                Money::from("5 ETH"),
1381            )],
1382            vec![],
1383            true,
1384            uuid4(),
1385            0.into(),
1386            0.into(),
1387            None,
1388        );
1389        wallet_account.apply(reduced).unwrap();
1390        let reduced_balance = *wallet_account.balance(Some(Currency::ETH())).unwrap();
1391
1392        let recovered = AccountState::new(
1393            AccountId::from("SIM-001"),
1394            AccountType::Wallet,
1395            vec![AccountBalance::new(
1396                Money::from("10 ETH"),
1397                Money::from("0 ETH"),
1398                Money::from("10 ETH"),
1399            )],
1400            vec![],
1401            true,
1402            uuid4(),
1403            0.into(),
1404            0.into(),
1405            None,
1406        );
1407        wallet_account.apply(recovered).unwrap();
1408
1409        let recovered_balance = wallet_account.balance(Some(Currency::ETH())).unwrap();
1410        assert_eq!(reduced_balance.locked, Money::from("5 ETH"));
1411        assert_eq!(reduced_balance.free, Money::from("0 ETH"));
1412        assert_eq!(recovered_balance.locked, Money::from("8 ETH"));
1413        assert_eq!(recovered_balance.free, Money::from("2 ETH"));
1414    }
1415
1416    #[rstest]
1417    fn test_serde_round_trip_rederives_balances_without_transient_locks(
1418        mut wallet_account: WalletAccount,
1419        currency_pair_btcusdt: CurrencyPair,
1420    ) {
1421        let instrument_id = currency_pair_btcusdt.id;
1422        wallet_account
1423            .update_balance_locked(instrument_id, Money::from("2 ETH"))
1424            .unwrap();
1425
1426        let json = serde_json::to_string(&wallet_account).unwrap();
1427        let deserialized: WalletAccount = serde_json::from_str(&json).unwrap();
1428
1429        assert_eq!(deserialized.id, wallet_account.id);
1430        assert_eq!(deserialized.account_type, AccountType::Wallet);
1431        assert_eq!(deserialized.events(), wallet_account.events());
1432        assert!(deserialized.balances_locked.is_empty());
1433        let balance = deserialized.balance(Some(Currency::ETH())).unwrap();
1434        assert_eq!(balance.total, Money::from("10 ETH"));
1435        assert_eq!(balance.locked, Money::from("0 ETH"));
1436        assert_eq!(balance.free, Money::from("10 ETH"));
1437    }
1438
1439    #[rstest]
1440    #[case::non_wallet_account_type(
1441        |base: &mut serde_json::Value| base["account_type"] = serde_json::json!("CASH"),
1442        "Wallet account had a non-wallet account type"
1443    )]
1444    #[case::base_currency(
1445        |base: &mut serde_json::Value| base["base_currency"] = serde_json::json!("USD"),
1446        "Wallet account had a base currency"
1447    )]
1448    #[case::no_events(
1449        |base: &mut serde_json::Value| base["events"] = serde_json::json!([]),
1450        "Wallet account had no events"
1451    )]
1452    #[case::different_event_account_id(
1453        |base: &mut serde_json::Value| base["events"][0]["account_id"] = serde_json::json!("OTHER-001"),
1454        "Wallet account event had a different account ID"
1455    )]
1456    #[case::non_wallet_event_account_type(
1457        |base: &mut serde_json::Value| {
1458            base["events"][0]["account_type"] = serde_json::json!("CASH");
1459        },
1460        "Wallet account event had a non-wallet account type"
1461    )]
1462    #[case::event_base_currency(
1463        |base: &mut serde_json::Value| {
1464            base["events"][0]["base_currency"] = serde_json::json!("USD");
1465        },
1466        "Wallet account event had a base currency"
1467    )]
1468    #[case::event_margins(
1469        |base: &mut serde_json::Value| {
1470            let margin = MarginBalance::new(
1471                Money::from("1 USDC"),
1472                Money::from("1 USDC"),
1473                Some(InstrumentId::from("BTCUSDT-PERP.BINANCE")),
1474            );
1475            base["events"][0]["margins"] = serde_json::json!([margin]);
1476        },
1477        "Wallet account event had margin balances"
1478    )]
1479    #[case::duplicate_event_currency(
1480        |base: &mut serde_json::Value| {
1481            let balance = base["events"][0]["balances"][0].clone();
1482            base["events"][0]["balances"]
1483                .as_array_mut()
1484                .expect("balances should be an array")
1485                .push(balance);
1486        },
1487        "Wallet account balances had duplicate currency ETH"
1488    )]
1489    #[case::negative_starting_balance(
1490        |base: &mut serde_json::Value| {
1491            base["balances_starting"]["ETH"] = serde_json::json!("-10.00000000 ETH");
1492        },
1493        "Wallet account starting balance was negative"
1494    )]
1495    fn test_deserialize_rejects_invalid_wallet_account(
1496        wallet_account: WalletAccount,
1497        #[case] tamper: fn(&mut serde_json::Value),
1498        #[case] expected: &str,
1499    ) {
1500        let mut value = serde_json::to_value(&wallet_account).unwrap();
1501        tamper(&mut value["base"]);
1502
1503        let error = serde_json::from_value::<WalletAccount>(value).unwrap_err();
1504
1505        assert_eq!(error.to_string(), expected);
1506    }
1507
1508    #[rstest]
1509    fn test_calculate_balance_locked_buy(audusd_sim: CurrencyPair) {
1510        let wallet_account = wallet_with_total(Currency::USD(), 1_000_000_000_000_000_000);
1511        let balance_locked = wallet_account
1512            .calculate_balance_locked(
1513                &audusd_sim.into_any(),
1514                OrderSide::Buy,
1515                Quantity::from("25000"),
1516                Price::from("0.8"),
1517                None,
1518            )
1519            .unwrap();
1520
1521        assert_eq!(balance_locked, Money::from("20000 USD"));
1522    }
1523
1524    #[rstest]
1525    fn test_calculate_balance_locked_buy_ceil_to_currency_grid(audusd_sim: CurrencyPair) {
1526        let wallet_account = wallet_with_total(Currency::USD(), Money::from("1 USD").raw());
1527        let balance_locked = wallet_account
1528            .calculate_balance_locked(
1529                &audusd_sim.into_any(),
1530                OrderSide::Buy,
1531                Quantity::from("1"),
1532                Price::from("0.001"),
1533                None,
1534            )
1535            .unwrap();
1536
1537        assert_eq!(balance_locked, Money::from("0.01 USD"));
1538    }
1539
1540    #[rstest]
1541    fn test_validate_observed_balance_rejects_currency_mismatch() {
1542        let balance = AccountBalance {
1543            currency: Currency::AUD(),
1544            total: Money::from("10 USD"),
1545            locked: Money::from("0 USD"),
1546            free: Money::from("10 USD"),
1547        };
1548
1549        let error = WalletAccount::validate_observed_balance(balance).unwrap_err();
1550
1551        assert_eq!(
1552            error.to_string(),
1553            "Wallet account balance currency AUD precision 2 differed from total currency USD precision 2"
1554        );
1555    }
1556
1557    #[rstest]
1558    fn test_balance_from_locks_checked_rejects_reservation_currency_mismatch() {
1559        let usd = Currency::USD();
1560        let total = Money::from("100 USD");
1561        let balance = AccountBalance::new(total, Money::zero(usd), total);
1562        let mut balances_locked = AHashMap::new();
1563        balances_locked.insert(
1564            (InstrumentId::from("AUD/USD.SIM"), Currency::AUD()),
1565            Money::from("10 USD"),
1566        );
1567
1568        let error =
1569            WalletAccount::balance_from_locks_checked(balance, &balances_locked).unwrap_err();
1570
1571        assert_eq!(
1572            error.to_string(),
1573            "wallet reservation key currency AUD precision 2 differed from value currency USD precision 2"
1574        );
1575    }
1576
1577    #[rstest]
1578    fn test_calculate_balance_locked_buy_inverse_locks_base_currency(
1579        btcusd_bybit: CryptoPerpetual,
1580    ) {
1581        let wallet_account = wallet_with_total(Currency::BTC(), Money::from("100 BTC").raw());
1582        let balance_locked = wallet_account
1583            .calculate_balance_locked(
1584                &btcusd_bybit.into_any(),
1585                OrderSide::Buy,
1586                Quantity::from("100000"),
1587                Price::from("10000.0"),
1588                None,
1589            )
1590            .unwrap();
1591
1592        assert_eq!(balance_locked, Money::from("10 BTC"));
1593    }
1594
1595    #[rstest]
1596    fn test_calculate_balance_locked_buy_quanto_locks_settlement_currency(
1597        ethbtc_quanto: CryptoFuture,
1598    ) {
1599        let wallet_account = wallet_with_total(Currency::USDT(), Money::from("100 USDT").raw());
1600        let balance_locked = wallet_account
1601            .calculate_balance_locked(
1602                &ethbtc_quanto.into_any(),
1603                OrderSide::Buy,
1604                Quantity::from("5"),
1605                Price::from("0.036"),
1606                None,
1607            )
1608            .unwrap();
1609
1610        assert_eq!(balance_locked, Money::from("0.18 USDT"));
1611    }
1612
1613    #[rstest]
1614    fn test_equality_compares_account_ids(wallet_account_state: AccountState) {
1615        let account = WalletAccount::new(wallet_account_state.clone(), true);
1616        let same = WalletAccount::new(wallet_account_state.clone(), true);
1617        let mut other_state = wallet_account_state;
1618        other_state.account_id = AccountId::from("OTHER-001");
1619        let other = WalletAccount::new(other_state, true);
1620
1621        assert_eq!(account, same);
1622        assert_ne!(account, other);
1623    }
1624
1625    #[rstest]
1626    fn test_calculate_balance_locked_sell(audusd_sim: CurrencyPair) {
1627        let wallet_account = wallet_with_total(Currency::AUD(), 1_000_000_000_000_000_000);
1628        let balance_locked = wallet_account
1629            .calculate_balance_locked(
1630                &audusd_sim.into_any(),
1631                OrderSide::Sell,
1632                Quantity::from("2"),
1633                Price::from("0.8"),
1634                None,
1635            )
1636            .unwrap();
1637
1638        assert_eq!(balance_locked, Money::from("2 AUD"));
1639    }
1640
1641    #[cfg(feature = "defi")]
1642    #[rstest]
1643    fn test_calculate_balance_locked_buy_ceil_to_observed_currency_grid() {
1644        let base = test_currency("WBASE", 16);
1645        let quote = test_currency("WQUOTE", 16);
1646        let observed = test_currency("WQUOTE", 6);
1647        let instrument = test_currency_pair(base, quote);
1648        let scale = money_raw(10_i128.pow(u32::from(FIXED_PRECISION)));
1649        let grid = money_raw(10_i128.pow(u32::from(FIXED_PRECISION - observed.precision)));
1650        let wallet = wallet_with_total(observed, 10 * scale);
1651
1652        let locked = wallet
1653            .calculate_balance_locked(
1654                &instrument.into_any(),
1655                OrderSide::Buy,
1656                Quantity::from("1.55"),
1657                Price::from("3.123456"),
1658                None,
1659            )
1660            .unwrap();
1661
1662        assert_eq!(locked.currency, observed);
1663        assert_eq!(locked.currency.precision, 6);
1664        assert_eq!(locked.raw(), 4_841_357 * grid);
1665    }
1666
1667    #[rstest]
1668    fn test_calculate_pnls_buy(wallet_account: WalletAccount, currency_pair_btcusdt: CurrencyPair) {
1669        let order = OrderTestBuilder::new(crate::enums::OrderType::Market)
1670            .instrument_id(currency_pair_btcusdt.id())
1671            .side(OrderSide::Buy)
1672            .quantity(Quantity::from("1"))
1673            .build();
1674        let instrument_any = currency_pair_btcusdt.into_any();
1675        let fill = TestOrderEventStubs::filled(
1676            &order,
1677            &instrument_any,
1678            None,
1679            None,
1680            Some(Price::from("50000")),
1681            None,
1682            None,
1683            None,
1684            None,
1685            Some(AccountId::from("SIM-001")),
1686        );
1687        let fill_owned: crate::events::OrderFilled = fill.into();
1688
1689        let result = wallet_account
1690            .calculate_pnls(&instrument_any, &fill_owned, None)
1691            .unwrap();
1692
1693        assert_eq!(
1694            result,
1695            vec![Money::from("1 BTC"), Money::from("-50000 USDT")]
1696        );
1697    }
1698
1699    #[rstest]
1700    fn test_calculate_commission(wallet_account: WalletAccount, audusd_sim: CurrencyPair) {
1701        let fee_rates = MakerTakerFeeRates::new(dec!(0.00002), dec!(0.00002));
1702        let commission = wallet_account
1703            .calculate_commission(
1704                &audusd_sim.into_any(),
1705                Quantity::from("100000"),
1706                Price::from("0.8"),
1707                LiquiditySide::Taker,
1708                fee_rates,
1709                None,
1710            )
1711            .unwrap();
1712
1713        assert_eq!(commission, Money::from("1.60 USD"));
1714    }
1715
1716    #[rstest]
1717    fn test_calculate_commission_invalid_liquidity_side_returns_error(
1718        wallet_account: WalletAccount,
1719        audusd_sim: CurrencyPair,
1720    ) {
1721        let result = wallet_account.calculate_commission(
1722            &audusd_sim.into_any(),
1723            Quantity::from("1"),
1724            Price::from("1"),
1725            LiquiditySide::NoLiquiditySide,
1726            MakerTakerFeeRates::zero(),
1727            None,
1728        );
1729
1730        assert!(result.is_err());
1731    }
1732
1733    #[cfg(feature = "defi")]
1734    fn test_currency(code: &str, precision: u8) -> Currency {
1735        Currency::new(code, precision, 0, code, CurrencyType::Crypto)
1736    }
1737
1738    #[cfg(feature = "defi")]
1739    #[allow(
1740        clippy::useless_conversion,
1741        reason = "the raw width differs when high-precision is disabled"
1742    )]
1743    fn money_raw(raw: i128) -> MoneyRaw {
1744        raw.try_into().unwrap()
1745    }
1746
1747    #[cfg(feature = "defi")]
1748    fn test_currency_pair(base: Currency, quote: Currency) -> CurrencyPair {
1749        CurrencyPair::builder()
1750            .instrument_id(InstrumentId::from("WBASEWQUOTE.BLOCKCHAIN"))
1751            .raw_symbol(Symbol::from("WBASEWQUOTE"))
1752            .base_currency(base)
1753            .quote_currency(quote)
1754            .price_precision(16)
1755            .size_precision(16)
1756            .price_increment(Price::from_raw(1, 16))
1757            .size_increment(Quantity::from_raw(1, 16))
1758            .ts_event(0.into())
1759            .ts_init(0.into())
1760            .build()
1761            .unwrap()
1762    }
1763
1764    fn wallet_with_total(currency: Currency, raw: MoneyRaw) -> WalletAccount {
1765        let total = Money::from_raw(raw, currency);
1766        WalletAccount::new(
1767            AccountState::new(
1768                AccountId::from("WALLET-TEST"),
1769                AccountType::Wallet,
1770                vec![AccountBalance::new(total, Money::zero(currency), total)],
1771                vec![],
1772                true,
1773                uuid4(),
1774                0.into(),
1775                0.into(),
1776                None,
1777            ),
1778            true,
1779        )
1780    }
1781}