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