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