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