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