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