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