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