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