1use std::fmt::{Debug, Display};
19
20use nautilus_core::correctness::{
21 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_predicate_true,
22};
23use rust_decimal::Decimal;
24use serde::{
25 Deserialize, Deserializer, Serialize, Serializer,
26 de::IgnoredAny,
27 ser::{SerializeSeq, SerializeStruct},
28};
29
30use crate::{
31 enums::CurrencyType,
32 identifiers::InstrumentId,
33 types::{Currency, Money, fixed::FIXED_PRECISION, money::MoneyRaw},
34};
35
36#[derive(Copy, Clone, Serialize)]
38#[cfg_attr(
39 feature = "python",
40 pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, from_py_object)
41)]
42#[cfg_attr(
43 feature = "python",
44 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
45)]
46pub struct AccountBalance {
47 pub currency: Currency,
49 pub total: Money,
51 pub locked: Money,
53 pub free: Money,
55}
56
57impl AccountBalance {
58 pub fn new_checked(total: Money, locked: Money, free: Money) -> CorrectnessResult<Self> {
68 check_predicate_true(
69 total.currency == locked.currency,
70 &format!(
71 "`total` currency ({}) != `locked` currency ({})",
72 total.currency, locked.currency
73 ),
74 )?;
75 check_predicate_true(
76 total.currency == free.currency,
77 &format!(
78 "`total` currency ({}) != `free` currency ({})",
79 total.currency, free.currency
80 ),
81 )?;
82 check_predicate_true(
83 locked.checked_add(free) == Some(total),
84 &format!("`total` ({total}) - `locked` ({locked}) != `free` ({free})"),
85 )?;
86 Ok(Self {
87 currency: total.currency,
88 total,
89 locked,
90 free,
91 })
92 }
93
94 #[must_use]
100 pub fn new(total: Money, locked: Money, free: Money) -> Self {
101 Self::new_checked(total, locked, free).expect_display(FAILED)
102 }
103
104 pub fn from_total_and_locked(
119 total: Decimal,
120 locked: Decimal,
121 currency: Currency,
122 ) -> CorrectnessResult<Self> {
123 let total = Money::from_decimal(total, currency)?;
124 let locked = Money::from_decimal(locked, currency)?;
125
126 let clamped_locked = if total.is_negative() {
127 locked
128 } else {
129 locked.clamp(Money::zero(currency), total)
130 };
131
132 let free = total.checked_sub(clamped_locked).ok_or_else(|| {
133 CorrectnessError::PredicateViolation {
134 message: format!(
135 "Derived `free` exceeds Money bounds for `total` {total} and `locked` {clamped_locked}"
136 ),
137 }
138 })?;
139
140 Ok(Self::new(total, clamped_locked, free))
141 }
142
143 pub fn from_total_and_free(
157 total: Decimal,
158 free: Decimal,
159 currency: Currency,
160 ) -> CorrectnessResult<Self> {
161 let total = Money::from_decimal(total, currency)?;
162 let free = Money::from_decimal(free, currency)?;
163
164 let clamped_free = if total.is_negative() {
165 free
166 } else {
167 free.clamp(Money::zero(currency), total)
168 };
169
170 let locked = total.checked_sub(clamped_free).ok_or_else(|| {
171 CorrectnessError::PredicateViolation {
172 message: format!(
173 "Derived `locked` exceeds Money bounds for `total` {total} and `free` {clamped_free}"
174 ),
175 }
176 })?;
177
178 Ok(Self::new(total, locked, clamped_free))
179 }
180}
181
182pub(crate) struct WalletAccountBalances<'a> {
183 balances: &'a [AccountBalance],
184}
185
186impl<'a> WalletAccountBalances<'a> {
187 pub(crate) const fn new(balances: &'a [AccountBalance]) -> Self {
188 Self { balances }
189 }
190}
191
192impl Serialize for WalletAccountBalances<'_> {
193 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
194 where
195 S: Serializer,
196 {
197 let mut sequence = serializer.serialize_seq(Some(self.balances.len()))?;
198 for balance in self.balances {
199 sequence.serialize_element(&WalletAccountBalance(balance))?;
200 }
201 sequence.end()
202 }
203}
204
205struct WalletAccountBalance<'a>(&'a AccountBalance);
206
207impl Serialize for WalletAccountBalance<'_> {
208 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
209 where
210 S: Serializer,
211 {
212 let balance = self.0;
213 for money in [balance.total, balance.locked, balance.free] {
214 if !has_same_currency_identity(balance.currency, money.currency) {
215 return Err(serde::ser::Error::custom(format!(
216 "Wallet account balance currency identity {} does not match {money}",
217 balance.currency
218 )));
219 }
220 }
221
222 let mut state = serializer.serialize_struct("AccountBalance", 8)?;
223 state.serialize_field("currency", &balance.currency)?;
224 state.serialize_field("total", &balance.total)?;
225 state.serialize_field("locked", &balance.locked)?;
226 state.serialize_field("free", &balance.free)?;
227 state.serialize_field(
228 "currency_identity",
229 &CurrencyIdentity::from(balance.currency),
230 )?;
231 state.serialize_field(
232 "total_minor",
233 &minor_units(balance.total).map_err(serde::ser::Error::custom)?,
234 )?;
235 state.serialize_field(
236 "locked_minor",
237 &minor_units(balance.locked).map_err(serde::ser::Error::custom)?,
238 )?;
239 state.serialize_field(
240 "free_minor",
241 &minor_units(balance.free).map_err(serde::ser::Error::custom)?,
242 )?;
243 state.end()
244 }
245}
246
247#[derive(Serialize, Deserialize)]
248struct CurrencyIdentity {
249 code: String,
250 precision: u8,
251 iso4217: u16,
252 name: String,
253 currency_type: CurrencyType,
254}
255
256impl From<Currency> for CurrencyIdentity {
257 fn from(currency: Currency) -> Self {
258 Self {
259 code: currency.code.to_string(),
260 precision: currency.precision,
261 iso4217: currency.iso4217,
262 name: currency.name.to_string(),
263 currency_type: currency.currency_type,
264 }
265 }
266}
267
268#[derive(Deserialize)]
269struct WalletAccountBalanceOwned {
270 #[serde(rename = "currency")]
271 _legacy_currency: IgnoredAny,
272 #[serde(rename = "total")]
273 _legacy_total: IgnoredAny,
274 #[serde(rename = "locked")]
275 _legacy_locked: IgnoredAny,
276 #[serde(rename = "free")]
277 _legacy_free: IgnoredAny,
278 currency_identity: CurrencyIdentity,
279 total_minor: String,
280 locked_minor: String,
281 free_minor: String,
282}
283
284#[derive(Deserialize)]
285struct AccountBalanceLegacy {
286 currency: Currency,
287 total: Money,
288 locked: Money,
289 free: Money,
290}
291
292impl<'de> Deserialize<'de> for AccountBalance {
293 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
294 where
295 D: Deserializer<'de>,
296 {
297 let value = serde_json::Value::deserialize(deserializer)?;
298 if value
299 .as_object()
300 .is_some_and(|balance| balance.contains_key("currency_identity"))
301 {
302 let balance =
303 WalletAccountBalanceOwned::deserialize(value).map_err(serde::de::Error::custom)?;
304 let currency = Currency::new_checked(
305 balance.currency_identity.code,
306 balance.currency_identity.precision,
307 balance.currency_identity.iso4217,
308 balance.currency_identity.name,
309 balance.currency_identity.currency_type,
310 )
311 .map_err(serde::de::Error::custom)?;
312 let total = money_from_minor_units(&balance.total_minor, currency)
313 .map_err(serde::de::Error::custom)?;
314 let locked = money_from_minor_units(&balance.locked_minor, currency)
315 .map_err(serde::de::Error::custom)?;
316 let free = money_from_minor_units(&balance.free_minor, currency)
317 .map_err(serde::de::Error::custom)?;
318 Self::new_checked(total, locked, free).map_err(serde::de::Error::custom)
319 } else {
320 let balance =
321 AccountBalanceLegacy::deserialize(value).map_err(serde::de::Error::custom)?;
322 Ok(Self {
323 currency: balance.currency,
324 total: balance.total,
325 locked: balance.locked,
326 free: balance.free,
327 })
328 }
329 }
330}
331
332fn has_same_currency_identity(left: Currency, right: Currency) -> bool {
333 left.code == right.code
334 && left.precision == right.precision
335 && left.iso4217 == right.iso4217
336 && left.name == right.name
337 && left.currency_type == right.currency_type
338}
339
340#[allow(
341 clippy::useless_conversion,
342 reason = "i128::from narrows MoneyRaw when high-precision is disabled"
343)]
344fn minor_units(money: Money) -> Result<String, String> {
345 let scale = raw_per_minor(money.currency.precision);
346 let raw = i128::from(money.raw());
347 if raw % scale != 0 {
348 return Err(format!(
349 "Wallet money raw value {} is not aligned to currency precision {}",
350 money.raw(),
351 money.currency.precision
352 ));
353 }
354 Ok((raw / scale).to_string())
355}
356
357#[allow(
358 clippy::useless_conversion,
359 reason = "MoneyRaw::try_from narrows i128 when high-precision is disabled"
360)]
361fn money_from_minor_units(value: &str, currency: Currency) -> Result<Money, String> {
362 let minor = value
363 .parse::<i128>()
364 .map_err(|e| format!("Invalid wallet money minor units '{value}': {e}"))?;
365 let scale = raw_per_minor(currency.precision);
366 let raw = minor.checked_mul(scale).ok_or_else(|| {
367 format!(
368 "Wallet money minor units {minor} overflow at currency precision {}",
369 currency.precision
370 )
371 })?;
372 let raw = MoneyRaw::try_from(raw).map_err(|e| {
373 format!(
374 "Wallet money minor units {minor} exceed the raw range at currency precision {}: {e}",
375 currency.precision
376 )
377 })?;
378 Money::from_raw_checked(raw, currency).map_err(|e| e.to_string())
379}
380
381fn raw_per_minor(precision: u8) -> i128 {
382 10_i128.pow(u32::from(FIXED_PRECISION.saturating_sub(precision)))
383}
384
385impl PartialEq for AccountBalance {
386 fn eq(&self, other: &Self) -> bool {
387 self.total == other.total && self.locked == other.locked && self.free == other.free
388 }
389}
390
391impl Debug for AccountBalance {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 write!(
394 f,
395 "{}(total={}, locked={}, free={})",
396 stringify!(AccountBalance),
397 self.total,
398 self.locked,
399 self.free,
400 )
401 }
402}
403
404impl Display for AccountBalance {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 write!(f, "{self:?}")
407 }
408}
409
410#[derive(Copy, Clone, Serialize, Deserialize)]
411#[cfg_attr(
412 feature = "python",
413 pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, from_py_object)
414)]
415#[cfg_attr(
416 feature = "python",
417 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
418)]
419pub struct MarginBalance {
430 pub initial: Money,
431 pub maintenance: Money,
432 pub currency: Currency,
433 pub instrument_id: Option<InstrumentId>,
434}
435
436impl MarginBalance {
437 pub fn new_checked(
447 initial: Money,
448 maintenance: Money,
449 instrument_id: Option<InstrumentId>,
450 ) -> CorrectnessResult<Self> {
451 check_predicate_true(
452 initial.currency == maintenance.currency,
453 &format!(
454 "`initial` currency ({}) != `maintenance` currency ({})",
455 initial.currency, maintenance.currency
456 ),
457 )?;
458 Ok(Self {
459 initial,
460 maintenance,
461 currency: initial.currency,
462 instrument_id,
463 })
464 }
465
466 #[must_use]
472 pub fn new(initial: Money, maintenance: Money, instrument_id: Option<InstrumentId>) -> Self {
473 Self::new_checked(initial, maintenance, instrument_id).expect_display(FAILED)
474 }
475}
476
477impl PartialEq for MarginBalance {
478 fn eq(&self, other: &Self) -> bool {
479 self.initial == other.initial
480 && self.maintenance == other.maintenance
481 && self.instrument_id == other.instrument_id
482 }
483}
484
485impl Debug for MarginBalance {
486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 match self.instrument_id {
488 Some(id) => write!(
489 f,
490 "{}(initial={}, maintenance={}, instrument_id={})",
491 stringify!(MarginBalance),
492 self.initial,
493 self.maintenance,
494 id,
495 ),
496 None => write!(
497 f,
498 "{}(initial={}, maintenance={}, currency={})",
499 stringify!(MarginBalance),
500 self.initial,
501 self.maintenance,
502 self.currency,
503 ),
504 }
505 }
506}
507
508impl Display for MarginBalance {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 write!(f, "{self:?}")
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use nautilus_core::correctness::CorrectnessError;
517 use rstest::rstest;
518 use rust_decimal::Decimal;
519 use rust_decimal_macros::dec;
520
521 use super::{has_same_currency_identity, money_from_minor_units};
522 use crate::{
523 enums::CurrencyType,
524 identifiers::InstrumentId,
525 types::{
526 AccountBalance, Currency, MarginBalance, Money,
527 stubs::{stub_account_balance, stub_margin_balance},
528 },
529 };
530
531 #[rstest]
532 fn test_has_same_currency_identity_requires_every_field() {
533 let usd = Currency::USD();
534
535 assert!(has_same_currency_identity(
536 usd,
537 Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat)
538 ));
539 assert!(!has_same_currency_identity(
540 usd,
541 Currency::new("XXX", 2, 840, "United States dollar", CurrencyType::Fiat)
542 ));
543 assert!(!has_same_currency_identity(
544 usd,
545 Currency::new("USD", 8, 840, "United States dollar", CurrencyType::Fiat)
546 ));
547 assert!(!has_same_currency_identity(
548 usd,
549 Currency::new("USD", 2, 0, "United States dollar", CurrencyType::Fiat)
550 ));
551 assert!(!has_same_currency_identity(
552 usd,
553 Currency::new("USD", 2, 840, "US dollar", CurrencyType::Fiat)
554 ));
555 assert!(!has_same_currency_identity(
556 usd,
557 Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Crypto)
558 ));
559 }
560
561 #[rstest]
562 fn test_margin_balance_equality_compares_every_field() {
563 let instrument_id = InstrumentId::from("AUD/USD.SIM");
564
565 let balance = MarginBalance::new(
566 Money::from("100 USD"),
567 Money::from("50 USD"),
568 Some(instrument_id),
569 );
570
571 assert_eq!(
572 balance,
573 MarginBalance::new(
574 Money::from("100 USD"),
575 Money::from("50 USD"),
576 Some(instrument_id),
577 )
578 );
579 assert_ne!(
580 balance,
581 MarginBalance::new(
582 Money::from("100 USD"),
583 Money::from("60 USD"),
584 Some(instrument_id),
585 )
586 );
587 assert_ne!(
588 balance,
589 MarginBalance::new(
590 Money::from("200 USD"),
591 Money::from("50 USD"),
592 Some(instrument_id),
593 )
594 );
595 assert_ne!(
596 balance,
597 MarginBalance::new(Money::from("100 USD"), Money::from("50 USD"), None)
598 );
599 }
600
601 #[rstest]
602 fn test_account_balance_equality_compares_every_amount() {
603 let balance = AccountBalance::new(
604 Money::from("100 USD"),
605 Money::from("25 USD"),
606 Money::from("75 USD"),
607 );
608
609 assert_eq!(
610 balance,
611 AccountBalance::new(
612 Money::from("100 USD"),
613 Money::from("25 USD"),
614 Money::from("75 USD"),
615 )
616 );
617 assert_ne!(
618 balance,
619 AccountBalance::new(
620 Money::from("100 USD"),
621 Money::from("50 USD"),
622 Money::from("50 USD"),
623 )
624 );
625 assert_ne!(
626 balance,
627 AccountBalance::new(
628 Money::from("200 USD"),
629 Money::from("125 USD"),
630 Money::from("75 USD"),
631 )
632 );
633 }
634
635 #[rstest]
636 fn test_account_balance_equality() {
637 let account_balance_1 = stub_account_balance();
638 let account_balance_2 = stub_account_balance();
639 assert_eq!(account_balance_1, account_balance_2);
640 }
641
642 #[rstest]
643 fn test_account_balance_debug(stub_account_balance: AccountBalance) {
644 let result = format!("{stub_account_balance:?}");
645 let expected =
646 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
647 assert_eq!(result, expected);
648 }
649
650 #[rstest]
651 fn test_account_balance_display(stub_account_balance: AccountBalance) {
652 let result = format!("{stub_account_balance}");
653 let expected =
654 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
655 assert_eq!(result, expected);
656 }
657
658 #[rstest]
659 #[case::locked(
660 Currency::EUR(),
661 Currency::USD(),
662 "`total` currency (USD) != `locked` currency (EUR)"
663 )]
664 #[case::free(
665 Currency::USD(),
666 Currency::EUR(),
667 "`total` currency (USD) != `free` currency (EUR)"
668 )]
669 fn test_account_balance_new_checked_with_currency_mismatch_returns_error(
670 #[case] locked_currency: Currency,
671 #[case] free_currency: Currency,
672 #[case] message: &str,
673 ) {
674 let usd = Currency::USD();
675 let error = AccountBalance::new_checked(
676 Money::new(1000.0, usd),
677 Money::new(250.0, locked_currency),
678 Money::new(750.0, free_currency),
679 )
680 .unwrap_err();
681
682 assert_eq!(
683 error,
684 CorrectnessError::PredicateViolation {
685 message: message.to_string(),
686 }
687 );
688 }
689
690 #[rstest]
691 #[should_panic(expected = "`total` currency (USD) != `locked` currency (EUR)")]
692 fn test_account_balance_new_with_currency_mismatch_panics() {
693 let usd = Currency::USD();
694 let eur = Currency::EUR();
695 let _ = AccountBalance::new(
696 Money::new(1000.0, usd),
697 Money::new(250.0, eur),
698 Money::new(750.0, usd),
699 );
700 }
701
702 #[rstest]
703 fn test_money_from_minor_units_rejects_invalid_integer() {
704 let error = money_from_minor_units("invalid", Currency::USD()).unwrap_err();
705
706 assert_eq!(
707 error,
708 "Invalid wallet money minor units 'invalid': invalid digit found in string"
709 );
710 }
711
712 #[rstest]
713 fn test_money_from_minor_units_rejects_scaling_overflow() {
714 let value = i128::MAX.to_string();
715 let error = money_from_minor_units(&value, Currency::USD()).unwrap_err();
716
717 assert_eq!(
718 error,
719 format!(
720 "Wallet money minor units {} overflow at currency precision 2",
721 i128::MAX
722 )
723 );
724 }
725
726 fn parse_dec(s: &str) -> Decimal {
727 s.parse().unwrap()
728 }
729
730 #[rstest]
731 #[case::zero_zero_usd("0", "0")]
732 #[case::total_zero_positive_locked_usd("0", "5")]
733 #[case::round_usd("1000", "250")]
734 #[case::free_is_zero_usd("1000", "1000")]
735 #[case::locked_is_zero_usd("1000", "0")]
736 #[case::fractional_usd("1234.56", "789.01")]
737 #[case::fractional_btc("10.12345678", "2.87654321")]
738 #[case::small_btc("0.00000001", "0")]
739 #[case::large_usd("1000000000.00", "123.45")]
740 #[case::drift_af_btc("10.000000035", "10.000000031")]
741 #[case::drift_locked_over_precision_btc("10.000000034999", "0.000000004999")]
742 #[case::locked_above_total_usd("100", "150")]
743 #[case::locked_above_total_btc("1.50000000", "5.00000000")]
744 #[case::negative_locked_usd("100", "-5")]
745 #[case::negative_locked_btc("0.50000000", "-0.00000001")]
746 #[case::negative_total_with_reserved("-10", "5")]
747 #[case::negative_total_negative_locked("-10", "-5")]
748 #[case::deep_underwater_with_reserved("-100", "50")]
749 fn test_from_total_and_locked_preserves_invariant(
750 #[case] total_str: &str,
751 #[case] locked_str: &str,
752 ) {
753 for currency in [Currency::USD(), Currency::BTC()] {
754 let total = parse_dec(total_str);
755 let locked = parse_dec(locked_str);
756 let balance = AccountBalance::from_total_and_locked(total, locked, currency).unwrap();
757
758 assert_eq!(
759 balance.total,
760 balance.locked + balance.free,
761 "invariant violated for total={total}, locked={locked}, currency={}",
762 currency.code,
763 );
764 if !balance.total.is_negative() {
767 assert!(
768 !balance.locked.is_negative(),
769 "locked must be non-negative for non-negative total (found raw={})",
770 balance.locked.raw(),
771 );
772 }
773 assert_eq!(balance.total.currency, currency);
774 assert_eq!(balance.locked.currency, currency);
775 assert_eq!(balance.free.currency, currency);
776 }
777 }
778
779 #[rstest]
780 #[case::zero_zero_usd("0", "0")]
781 #[case::round_usd("1000", "750")]
782 #[case::free_equals_total_usd("1000", "1000")]
783 #[case::free_is_zero_usd("1000", "0")]
784 #[case::fractional_usd("1234.56", "444.55")]
785 #[case::fractional_btc("10.12345678", "7.24691356")]
786 #[case::drift_over_precision_btc("10.000000034999", "9.999999994999")]
787 #[case::free_above_total_usd("100", "120")]
788 #[case::free_above_total_btc("0.50000000", "0.99999999")]
789 #[case::negative_free_usd("100", "-5")]
790 #[case::negative_total_usd("-10", "0")]
791 #[case::negative_total_positive_free("-10", "5")]
792 fn test_from_total_and_free_preserves_invariant(
793 #[case] total_str: &str,
794 #[case] free_str: &str,
795 ) {
796 for currency in [Currency::USD(), Currency::BTC()] {
797 let total = parse_dec(total_str);
798 let free = parse_dec(free_str);
799 let balance = AccountBalance::from_total_and_free(total, free, currency).unwrap();
800
801 assert_eq!(
802 balance.total,
803 balance.locked + balance.free,
804 "invariant violated for total={total}, free={free}, currency={}",
805 currency.code,
806 );
807
808 if !balance.total.is_negative() {
809 assert!(
810 !balance.free.is_negative(),
811 "free must be non-negative for non-negative total (found raw={})",
812 balance.free.raw(),
813 );
814 }
815 assert_eq!(balance.total.currency, currency);
816 assert_eq!(balance.locked.currency, currency);
817 assert_eq!(balance.free.currency, currency);
818 }
819 }
820
821 #[rstest]
822 #[case::usd_basic(dec!(1000.00), dec!(250.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
823 #[case::usd_all_free(dec!(500.00), dec!(0.00), dec!(500.00), dec!(0.00), dec!(500.00))]
824 #[case::usd_all_locked(dec!(500.00), dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00))]
825 #[case::usd_clamp_above(dec!(100.00), dec!(150.00), dec!(100.00), dec!(100.00), dec!(0.00))]
826 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(0.00), dec!(100.00))]
827 fn test_from_total_and_locked_exact_usd(
828 #[case] total_in: Decimal,
829 #[case] locked_in: Decimal,
830 #[case] expected_total: Decimal,
831 #[case] expected_locked: Decimal,
832 #[case] expected_free: Decimal,
833 ) {
834 let usd = Currency::USD();
835 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
836
837 assert_eq!(
838 balance.total,
839 Money::from_decimal(expected_total, usd).unwrap()
840 );
841 assert_eq!(
842 balance.locked,
843 Money::from_decimal(expected_locked, usd).unwrap()
844 );
845 assert_eq!(
846 balance.free,
847 Money::from_decimal(expected_free, usd).unwrap()
848 );
849 }
850
851 #[rstest]
852 #[case::usd_basic(dec!(1000.00), dec!(750.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
853 #[case::usd_all_free(dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00), dec!(500.00))]
854 #[case::usd_all_locked(dec!(500.00), dec!(0.00), dec!(500.00), dec!(500.00), dec!(0.00))]
855 #[case::usd_clamp_above(dec!(100.00), dec!(120.00), dec!(100.00), dec!(0.00), dec!(100.00))]
856 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(100.00), dec!(0.00))]
857 fn test_from_total_and_free_exact_usd(
858 #[case] total_in: Decimal,
859 #[case] free_in: Decimal,
860 #[case] expected_total: Decimal,
861 #[case] expected_locked: Decimal,
862 #[case] expected_free: Decimal,
863 ) {
864 let usd = Currency::USD();
865 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
866
867 assert_eq!(
868 balance.total,
869 Money::from_decimal(expected_total, usd).unwrap()
870 );
871 assert_eq!(
872 balance.locked,
873 Money::from_decimal(expected_locked, usd).unwrap()
874 );
875 assert_eq!(
876 balance.free,
877 Money::from_decimal(expected_free, usd).unwrap()
878 );
879 }
880
881 #[rstest]
885 fn test_from_total_and_locked_issue_3867_drift() {
886 let btc = Currency::BTC();
887 let af = parse_dec("0.000000035");
888 let amount = parse_dec("10") + af;
889 let locked = amount - af;
890
891 let balance = AccountBalance::from_total_and_locked(amount, locked, btc).unwrap();
892
893 assert_eq!(balance.total, balance.locked + balance.free);
894 }
895
896 #[rstest]
897 #[case(dec!(0), dec!(100))]
898 #[case(dec!(1), dec!(1000000))]
899 #[case(dec!(500), dec!(500000))]
900 fn test_from_total_and_locked_non_negative_total_never_leaves_free_negative(
901 #[case] total: Decimal,
902 #[case] locked: Decimal,
903 ) {
904 let usd = Currency::USD();
905 let balance = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
906 assert!(
907 !balance.free.is_negative(),
908 "free went negative: total={total}, locked={locked}"
909 );
910 assert_eq!(balance.total, balance.locked + balance.free);
911 }
912
913 #[rstest]
914 #[case(dec!(1000.00), dec!(250.00), dec!(750.00))]
915 #[case(dec!(0.00), dec!(0.00), dec!(0.00))]
916 #[case(dec!(500.00), dec!(500.00), dec!(0.00))]
917 #[case(dec!(500.00), dec!(0.00), dec!(500.00))]
918 fn test_locked_and_free_forms_agree_when_consistent(
919 #[case] total: Decimal,
920 #[case] locked: Decimal,
921 #[case] free: Decimal,
922 ) {
923 let usd = Currency::USD();
924 let from_locked = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
925 let from_free = AccountBalance::from_total_and_free(total, free, usd).unwrap();
926 assert_eq!(from_locked, from_free);
927 }
928
929 #[rstest]
930 #[case::borrow_deficit(dec!(-100), dec!(50), dec!(-100), dec!(50), dec!(-150))]
931 #[case::underwater_no_reserve(dec!(-10), dec!(0), dec!(-10), dec!(0), dec!(-10))]
932 #[case::negative_locked_passed_through(dec!(-10), dec!(-5), dec!(-10), dec!(-5), dec!(-5))]
933 fn test_from_total_and_locked_preserves_reserved_on_negative_total(
934 #[case] total_in: Decimal,
935 #[case] locked_in: Decimal,
936 #[case] expected_total: Decimal,
937 #[case] expected_locked: Decimal,
938 #[case] expected_free: Decimal,
939 ) {
940 let usd = Currency::USD();
941 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
942
943 assert_eq!(
944 balance.total,
945 Money::from_decimal(expected_total, usd).unwrap()
946 );
947 assert_eq!(
948 balance.locked,
949 Money::from_decimal(expected_locked, usd).unwrap()
950 );
951 assert_eq!(
952 balance.free,
953 Money::from_decimal(expected_free, usd).unwrap()
954 );
955 assert_eq!(balance.total, balance.locked + balance.free);
956 }
957
958 #[rstest]
959 #[case::available_below_total(dec!(-100), dec!(-150), dec!(-100), dec!(50), dec!(-150))]
960 #[case::available_zero_preserved(dec!(-100), dec!(0), dec!(-100), dec!(-100), dec!(0))]
961 fn test_from_total_and_free_preserves_available_on_negative_total(
962 #[case] total_in: Decimal,
963 #[case] free_in: Decimal,
964 #[case] expected_total: Decimal,
965 #[case] expected_locked: Decimal,
966 #[case] expected_free: Decimal,
967 ) {
968 let usd = Currency::USD();
969 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
970
971 assert_eq!(
972 balance.total,
973 Money::from_decimal(expected_total, usd).unwrap()
974 );
975 assert_eq!(
976 balance.locked,
977 Money::from_decimal(expected_locked, usd).unwrap()
978 );
979 assert_eq!(
980 balance.free,
981 Money::from_decimal(expected_free, usd).unwrap()
982 );
983 assert_eq!(balance.total, balance.locked + balance.free);
984 }
985
986 #[rstest]
987 fn test_from_total_and_locked_invalid_decimal_returns_error() {
988 let btc = Currency::BTC();
989 let too_large: Decimal = "79228162514264337593543950335".parse().unwrap();
992 let result = AccountBalance::from_total_and_locked(too_large, dec!(0), btc);
993 assert!(result.is_err());
994 }
995
996 #[rstest]
997 fn test_new_checked_extreme_values_returns_error_without_panicking() {
998 use crate::types::money::MONEY_MAX;
999
1000 let usd = Currency::USD();
1003 let max = Money::new(MONEY_MAX, usd);
1004
1005 let error = AccountBalance::new_checked(max, max, max).unwrap_err();
1006 assert!(
1007 error.to_string().contains("`total`"),
1008 "unexpected message: {error}"
1009 );
1010 }
1011
1012 #[rstest]
1013 fn test_from_total_and_locked_extreme_bounds_returns_error() {
1014 use crate::types::money::{MONEY_MAX, MONEY_MIN};
1015
1016 let usd = Currency::USD();
1019 let total = Money::new(MONEY_MIN, usd).as_decimal();
1020 let locked = Money::new(MONEY_MAX, usd).as_decimal();
1021
1022 let error = AccountBalance::from_total_and_locked(total, locked, usd).unwrap_err();
1023 assert!(
1024 error.to_string().contains("Money"),
1025 "unexpected message: {error}"
1026 );
1027 }
1028
1029 #[rstest]
1030 fn test_from_total_and_free_extreme_bounds_returns_error() {
1031 use crate::types::money::{MONEY_MAX, MONEY_MIN};
1032
1033 let usd = Currency::USD();
1034 let total = Money::new(MONEY_MIN, usd).as_decimal();
1035 let free = Money::new(MONEY_MAX, usd).as_decimal();
1036
1037 let error = AccountBalance::from_total_and_free(total, free, usd).unwrap_err();
1038 assert!(
1039 error.to_string().contains("Money"),
1040 "unexpected message: {error}"
1041 );
1042 }
1043
1044 #[rstest]
1045 fn test_margin_balance_equality() {
1046 let margin_balance_1 = stub_margin_balance();
1047 let margin_balance_2 = stub_margin_balance();
1048 assert_eq!(margin_balance_1, margin_balance_2);
1049 }
1050
1051 #[rstest]
1052 fn test_margin_balance_debug(stub_margin_balance: MarginBalance) {
1053 let display = format!("{stub_margin_balance:?}");
1054 assert_eq!(
1055 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
1056 display
1057 );
1058 }
1059
1060 #[rstest]
1061 fn test_margin_balance_display(stub_margin_balance: MarginBalance) {
1062 let display = format!("{stub_margin_balance}");
1063 assert_eq!(
1064 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
1065 display
1066 );
1067 }
1068
1069 #[rstest]
1070 fn test_margin_balance_new_checked_with_currency_mismatch_returns_error() {
1071 let usd = Currency::USD();
1072 let eur = Currency::EUR();
1073 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
1074 let result = MarginBalance::new_checked(
1075 Money::new(5000.0, usd),
1076 Money::new(20000.0, eur),
1077 Some(instrument_id),
1078 );
1079 assert!(result.is_err());
1080 }
1081
1082 #[rstest]
1083 #[should_panic(expected = "`initial` currency (USD) != `maintenance` currency (EUR)")]
1084 fn test_margin_balance_new_with_currency_mismatch_panics() {
1085 let usd = Currency::USD();
1086 let eur = Currency::EUR();
1087 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
1088 let _ = MarginBalance::new(
1089 Money::new(5000.0, usd),
1090 Money::new(20000.0, eur),
1091 Some(instrument_id),
1092 );
1093 }
1094
1095 #[rstest]
1096 fn test_margin_balance_account_scope_display() {
1097 let usd = Currency::USD();
1098 let balance = MarginBalance::new(Money::new(500.0, usd), Money::new(200.0, usd), None);
1099 assert_eq!(
1100 "MarginBalance(initial=500.00 USD, maintenance=200.00 USD, currency=USD)",
1101 format!("{balance}")
1102 );
1103 }
1104}