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::{Deserialize, Serialize};
25
26use crate::{
27 identifiers::InstrumentId,
28 types::{Currency, Money},
29};
30
31#[derive(Copy, Clone, Serialize, Deserialize)]
33#[cfg_attr(
34 feature = "python",
35 pyo3::pyclass(
36 module = "nautilus_trader.core.nautilus_pyo3.model",
37 frozen,
38 eq,
39 from_py_object
40 )
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
180impl PartialEq for AccountBalance {
181 fn eq(&self, other: &Self) -> bool {
182 self.total == other.total && self.locked == other.locked && self.free == other.free
183 }
184}
185
186impl Debug for AccountBalance {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(
189 f,
190 "{}(total={}, locked={}, free={})",
191 stringify!(AccountBalance),
192 self.total,
193 self.locked,
194 self.free,
195 )
196 }
197}
198
199impl Display for AccountBalance {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 write!(f, "{self:?}")
202 }
203}
204
205#[derive(Copy, Clone, Serialize, Deserialize)]
206#[cfg_attr(
207 feature = "python",
208 pyo3::pyclass(
209 module = "nautilus_trader.core.nautilus_pyo3.model",
210 frozen,
211 eq,
212 from_py_object
213 )
214)]
215#[cfg_attr(
216 feature = "python",
217 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
218)]
219pub struct MarginBalance {
230 pub initial: Money,
231 pub maintenance: Money,
232 pub currency: Currency,
233 pub instrument_id: Option<InstrumentId>,
234}
235
236impl MarginBalance {
237 pub fn new_checked(
247 initial: Money,
248 maintenance: Money,
249 instrument_id: Option<InstrumentId>,
250 ) -> CorrectnessResult<Self> {
251 check_predicate_true(
252 initial.currency == maintenance.currency,
253 &format!(
254 "`initial` currency ({}) != `maintenance` currency ({})",
255 initial.currency, maintenance.currency
256 ),
257 )?;
258 Ok(Self {
259 initial,
260 maintenance,
261 currency: initial.currency,
262 instrument_id,
263 })
264 }
265
266 #[must_use]
272 pub fn new(initial: Money, maintenance: Money, instrument_id: Option<InstrumentId>) -> Self {
273 Self::new_checked(initial, maintenance, instrument_id).expect_display(FAILED)
274 }
275}
276
277impl PartialEq for MarginBalance {
278 fn eq(&self, other: &Self) -> bool {
279 self.initial == other.initial
280 && self.maintenance == other.maintenance
281 && self.instrument_id == other.instrument_id
282 }
283}
284
285impl Debug for MarginBalance {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 match self.instrument_id {
288 Some(id) => write!(
289 f,
290 "{}(initial={}, maintenance={}, instrument_id={})",
291 stringify!(MarginBalance),
292 self.initial,
293 self.maintenance,
294 id,
295 ),
296 None => write!(
297 f,
298 "{}(initial={}, maintenance={}, currency={})",
299 stringify!(MarginBalance),
300 self.initial,
301 self.maintenance,
302 self.currency,
303 ),
304 }
305 }
306}
307
308impl Display for MarginBalance {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 write!(f, "{self:?}")
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use rstest::rstest;
317 use rust_decimal::Decimal;
318 use rust_decimal_macros::dec;
319
320 use crate::{
321 identifiers::InstrumentId,
322 types::{
323 AccountBalance, Currency, MarginBalance, Money,
324 stubs::{stub_account_balance, stub_margin_balance},
325 },
326 };
327
328 #[rstest]
329 fn test_account_balance_equality() {
330 let account_balance_1 = stub_account_balance();
331 let account_balance_2 = stub_account_balance();
332 assert_eq!(account_balance_1, account_balance_2);
333 }
334
335 #[rstest]
336 fn test_account_balance_debug(stub_account_balance: AccountBalance) {
337 let result = format!("{stub_account_balance:?}");
338 let expected =
339 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
340 assert_eq!(result, expected);
341 }
342
343 #[rstest]
344 fn test_account_balance_display(stub_account_balance: AccountBalance) {
345 let result = format!("{stub_account_balance}");
346 let expected =
347 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
348 assert_eq!(result, expected);
349 }
350
351 #[rstest]
352 fn test_account_balance_new_checked_with_currency_mismatch_returns_error() {
353 let usd = Currency::USD();
354 let eur = Currency::EUR();
355 let result = AccountBalance::new_checked(
356 Money::new(1000.0, usd),
357 Money::new(250.0, eur),
358 Money::new(750.0, usd),
359 );
360 assert!(result.is_err());
361 }
362
363 #[rstest]
364 #[should_panic(expected = "`total` currency (USD) != `locked` currency (EUR)")]
365 fn test_account_balance_new_with_currency_mismatch_panics() {
366 let usd = Currency::USD();
367 let eur = Currency::EUR();
368 let _ = AccountBalance::new(
369 Money::new(1000.0, usd),
370 Money::new(250.0, eur),
371 Money::new(750.0, usd),
372 );
373 }
374
375 fn parse_dec(s: &str) -> Decimal {
376 s.parse().unwrap()
377 }
378
379 #[rstest]
380 #[case::zero_zero_usd("0", "0")]
381 #[case::total_zero_positive_locked_usd("0", "5")]
382 #[case::round_usd("1000", "250")]
383 #[case::free_is_zero_usd("1000", "1000")]
384 #[case::locked_is_zero_usd("1000", "0")]
385 #[case::fractional_usd("1234.56", "789.01")]
386 #[case::fractional_btc("10.12345678", "2.87654321")]
387 #[case::small_btc("0.00000001", "0")]
388 #[case::large_usd("1000000000.00", "123.45")]
389 #[case::drift_af_btc("10.000000035", "10.000000031")]
390 #[case::drift_locked_over_precision_btc("10.000000034999", "0.000000004999")]
391 #[case::locked_above_total_usd("100", "150")]
392 #[case::locked_above_total_btc("1.50000000", "5.00000000")]
393 #[case::negative_locked_usd("100", "-5")]
394 #[case::negative_locked_btc("0.50000000", "-0.00000001")]
395 #[case::negative_total_with_reserved("-10", "5")]
396 #[case::negative_total_negative_locked("-10", "-5")]
397 #[case::deep_underwater_with_reserved("-100", "50")]
398 fn test_from_total_and_locked_preserves_invariant(
399 #[case] total_str: &str,
400 #[case] locked_str: &str,
401 ) {
402 for currency in [Currency::USD(), Currency::BTC()] {
403 let total = parse_dec(total_str);
404 let locked = parse_dec(locked_str);
405 let balance = AccountBalance::from_total_and_locked(total, locked, currency).unwrap();
406
407 assert_eq!(
408 balance.total.raw,
409 balance.locked.raw + balance.free.raw,
410 "invariant violated for total={total}, locked={locked}, currency={}",
411 currency.code,
412 );
413 if balance.total.raw >= 0 {
416 assert!(
417 balance.locked.raw >= 0,
418 "locked must be non-negative for non-negative total (found raw={})",
419 balance.locked.raw,
420 );
421 }
422 assert_eq!(balance.total.currency, currency);
423 assert_eq!(balance.locked.currency, currency);
424 assert_eq!(balance.free.currency, currency);
425 }
426 }
427
428 #[rstest]
429 #[case::zero_zero_usd("0", "0")]
430 #[case::round_usd("1000", "750")]
431 #[case::free_equals_total_usd("1000", "1000")]
432 #[case::free_is_zero_usd("1000", "0")]
433 #[case::fractional_usd("1234.56", "444.55")]
434 #[case::fractional_btc("10.12345678", "7.24691356")]
435 #[case::drift_over_precision_btc("10.000000034999", "9.999999994999")]
436 #[case::free_above_total_usd("100", "120")]
437 #[case::free_above_total_btc("0.50000000", "0.99999999")]
438 #[case::negative_free_usd("100", "-5")]
439 #[case::negative_total_usd("-10", "0")]
440 #[case::negative_total_positive_free("-10", "5")]
441 fn test_from_total_and_free_preserves_invariant(
442 #[case] total_str: &str,
443 #[case] free_str: &str,
444 ) {
445 for currency in [Currency::USD(), Currency::BTC()] {
446 let total = parse_dec(total_str);
447 let free = parse_dec(free_str);
448 let balance = AccountBalance::from_total_and_free(total, free, currency).unwrap();
449
450 assert_eq!(
451 balance.total.raw,
452 balance.locked.raw + balance.free.raw,
453 "invariant violated for total={total}, free={free}, currency={}",
454 currency.code,
455 );
456
457 if balance.total.raw >= 0 {
458 assert!(
459 balance.free.raw >= 0,
460 "free must be non-negative for non-negative total (found raw={})",
461 balance.free.raw,
462 );
463 }
464 assert_eq!(balance.total.currency, currency);
465 assert_eq!(balance.locked.currency, currency);
466 assert_eq!(balance.free.currency, currency);
467 }
468 }
469
470 #[rstest]
471 #[case::usd_basic(dec!(1000.00), dec!(250.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
472 #[case::usd_all_free(dec!(500.00), dec!(0.00), dec!(500.00), dec!(0.00), dec!(500.00))]
473 #[case::usd_all_locked(dec!(500.00), dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00))]
474 #[case::usd_clamp_above(dec!(100.00), dec!(150.00), dec!(100.00), dec!(100.00), dec!(0.00))]
475 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(0.00), dec!(100.00))]
476 fn test_from_total_and_locked_exact_usd(
477 #[case] total_in: Decimal,
478 #[case] locked_in: Decimal,
479 #[case] expected_total: Decimal,
480 #[case] expected_locked: Decimal,
481 #[case] expected_free: Decimal,
482 ) {
483 let usd = Currency::USD();
484 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
485
486 assert_eq!(
487 balance.total,
488 Money::from_decimal(expected_total, usd).unwrap()
489 );
490 assert_eq!(
491 balance.locked,
492 Money::from_decimal(expected_locked, usd).unwrap()
493 );
494 assert_eq!(
495 balance.free,
496 Money::from_decimal(expected_free, usd).unwrap()
497 );
498 }
499
500 #[rstest]
501 #[case::usd_basic(dec!(1000.00), dec!(750.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
502 #[case::usd_all_free(dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00), dec!(500.00))]
503 #[case::usd_all_locked(dec!(500.00), dec!(0.00), dec!(500.00), dec!(500.00), dec!(0.00))]
504 #[case::usd_clamp_above(dec!(100.00), dec!(120.00), dec!(100.00), dec!(0.00), dec!(100.00))]
505 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(100.00), dec!(0.00))]
506 fn test_from_total_and_free_exact_usd(
507 #[case] total_in: Decimal,
508 #[case] free_in: Decimal,
509 #[case] expected_total: Decimal,
510 #[case] expected_locked: Decimal,
511 #[case] expected_free: Decimal,
512 ) {
513 let usd = Currency::USD();
514 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
515
516 assert_eq!(
517 balance.total,
518 Money::from_decimal(expected_total, usd).unwrap()
519 );
520 assert_eq!(
521 balance.locked,
522 Money::from_decimal(expected_locked, usd).unwrap()
523 );
524 assert_eq!(
525 balance.free,
526 Money::from_decimal(expected_free, usd).unwrap()
527 );
528 }
529
530 #[rstest]
534 fn test_from_total_and_locked_issue_3867_drift() {
535 let btc = Currency::BTC();
536 let af = parse_dec("0.000000035");
537 let amount = parse_dec("10") + af;
538 let locked = amount - af;
539
540 let balance = AccountBalance::from_total_and_locked(amount, locked, btc).unwrap();
541
542 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
543 }
544
545 #[rstest]
546 #[case(dec!(0), dec!(100))]
547 #[case(dec!(1), dec!(1000000))]
548 #[case(dec!(500), dec!(500000))]
549 fn test_from_total_and_locked_non_negative_total_never_leaves_free_negative(
550 #[case] total: Decimal,
551 #[case] locked: Decimal,
552 ) {
553 let usd = Currency::USD();
554 let balance = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
555 assert!(
556 balance.free.raw >= 0,
557 "free went negative: total={total}, locked={locked}"
558 );
559 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
560 }
561
562 #[rstest]
563 #[case(dec!(1000.00), dec!(250.00), dec!(750.00))]
564 #[case(dec!(0.00), dec!(0.00), dec!(0.00))]
565 #[case(dec!(500.00), dec!(500.00), dec!(0.00))]
566 #[case(dec!(500.00), dec!(0.00), dec!(500.00))]
567 fn test_locked_and_free_forms_agree_when_consistent(
568 #[case] total: Decimal,
569 #[case] locked: Decimal,
570 #[case] free: Decimal,
571 ) {
572 let usd = Currency::USD();
573 let from_locked = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
574 let from_free = AccountBalance::from_total_and_free(total, free, usd).unwrap();
575 assert_eq!(from_locked, from_free);
576 }
577
578 #[rstest]
579 #[case::borrow_deficit(dec!(-100), dec!(50), dec!(-100), dec!(50), dec!(-150))]
580 #[case::underwater_no_reserve(dec!(-10), dec!(0), dec!(-10), dec!(0), dec!(-10))]
581 #[case::negative_locked_passed_through(dec!(-10), dec!(-5), dec!(-10), dec!(-5), dec!(-5))]
582 fn test_from_total_and_locked_preserves_reserved_on_negative_total(
583 #[case] total_in: Decimal,
584 #[case] locked_in: Decimal,
585 #[case] expected_total: Decimal,
586 #[case] expected_locked: Decimal,
587 #[case] expected_free: Decimal,
588 ) {
589 let usd = Currency::USD();
590 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
591
592 assert_eq!(
593 balance.total,
594 Money::from_decimal(expected_total, usd).unwrap()
595 );
596 assert_eq!(
597 balance.locked,
598 Money::from_decimal(expected_locked, usd).unwrap()
599 );
600 assert_eq!(
601 balance.free,
602 Money::from_decimal(expected_free, usd).unwrap()
603 );
604 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
605 }
606
607 #[rstest]
608 #[case::available_below_total(dec!(-100), dec!(-150), dec!(-100), dec!(50), dec!(-150))]
609 #[case::available_zero_preserved(dec!(-100), dec!(0), dec!(-100), dec!(-100), dec!(0))]
610 fn test_from_total_and_free_preserves_available_on_negative_total(
611 #[case] total_in: Decimal,
612 #[case] free_in: Decimal,
613 #[case] expected_total: Decimal,
614 #[case] expected_locked: Decimal,
615 #[case] expected_free: Decimal,
616 ) {
617 let usd = Currency::USD();
618 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
619
620 assert_eq!(
621 balance.total,
622 Money::from_decimal(expected_total, usd).unwrap()
623 );
624 assert_eq!(
625 balance.locked,
626 Money::from_decimal(expected_locked, usd).unwrap()
627 );
628 assert_eq!(
629 balance.free,
630 Money::from_decimal(expected_free, usd).unwrap()
631 );
632 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
633 }
634
635 #[rstest]
636 fn test_from_total_and_locked_invalid_decimal_returns_error() {
637 let btc = Currency::BTC();
638 let too_large: Decimal = "79228162514264337593543950335".parse().unwrap();
641 let result = AccountBalance::from_total_and_locked(too_large, dec!(0), btc);
642 assert!(result.is_err());
643 }
644
645 #[rstest]
646 fn test_new_checked_extreme_values_returns_error_without_panicking() {
647 use crate::types::money::MONEY_MAX;
648
649 let usd = Currency::USD();
652 let max = Money::new(MONEY_MAX, usd);
653
654 let error = AccountBalance::new_checked(max, max, max).unwrap_err();
655 assert!(
656 error.to_string().contains("`total`"),
657 "unexpected message: {error}"
658 );
659 }
660
661 #[rstest]
662 fn test_from_total_and_locked_extreme_bounds_returns_error() {
663 use crate::types::money::{MONEY_MAX, MONEY_MIN};
664
665 let usd = Currency::USD();
668 let total = Money::new(MONEY_MIN, usd).as_decimal();
669 let locked = Money::new(MONEY_MAX, usd).as_decimal();
670
671 let error = AccountBalance::from_total_and_locked(total, locked, usd).unwrap_err();
672 assert!(
673 error.to_string().contains("Money"),
674 "unexpected message: {error}"
675 );
676 }
677
678 #[rstest]
679 fn test_from_total_and_free_extreme_bounds_returns_error() {
680 use crate::types::money::{MONEY_MAX, MONEY_MIN};
681
682 let usd = Currency::USD();
683 let total = Money::new(MONEY_MIN, usd).as_decimal();
684 let free = Money::new(MONEY_MAX, usd).as_decimal();
685
686 let error = AccountBalance::from_total_and_free(total, free, usd).unwrap_err();
687 assert!(
688 error.to_string().contains("Money"),
689 "unexpected message: {error}"
690 );
691 }
692
693 #[rstest]
694 fn test_margin_balance_equality() {
695 let margin_balance_1 = stub_margin_balance();
696 let margin_balance_2 = stub_margin_balance();
697 assert_eq!(margin_balance_1, margin_balance_2);
698 }
699
700 #[rstest]
701 fn test_margin_balance_debug(stub_margin_balance: MarginBalance) {
702 let display = format!("{stub_margin_balance:?}");
703 assert_eq!(
704 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
705 display
706 );
707 }
708
709 #[rstest]
710 fn test_margin_balance_display(stub_margin_balance: MarginBalance) {
711 let display = format!("{stub_margin_balance}");
712 assert_eq!(
713 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
714 display
715 );
716 }
717
718 #[rstest]
719 fn test_margin_balance_new_checked_with_currency_mismatch_returns_error() {
720 let usd = Currency::USD();
721 let eur = Currency::EUR();
722 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
723 let result = MarginBalance::new_checked(
724 Money::new(5000.0, usd),
725 Money::new(20000.0, eur),
726 Some(instrument_id),
727 );
728 assert!(result.is_err());
729 }
730
731 #[rstest]
732 #[should_panic(expected = "`initial` currency (USD) != `maintenance` currency (EUR)")]
733 fn test_margin_balance_new_with_currency_mismatch_panics() {
734 let usd = Currency::USD();
735 let eur = Currency::EUR();
736 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
737 let _ = MarginBalance::new(
738 Money::new(5000.0, usd),
739 Money::new(20000.0, eur),
740 Some(instrument_id),
741 );
742 }
743
744 #[rstest]
745 fn test_margin_balance_account_scope_display() {
746 let usd = Currency::USD();
747 let balance = MarginBalance::new(Money::new(500.0, usd), Money::new(200.0, usd), None);
748 assert_eq!(
749 "MarginBalance(initial=500.00 USD, maintenance=200.00 USD, currency=USD)",
750 format!("{balance}")
751 );
752 }
753}