1use std::fmt::{Debug, Display};
19
20use nautilus_core::correctness::{
21 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 total == locked + free,
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 = Money::from_raw(total.raw - clamped_locked.raw, currency);
132 Ok(Self::new(total, clamped_locked, free))
133 }
134
135 pub fn from_total_and_free(
149 total: Decimal,
150 free: Decimal,
151 currency: Currency,
152 ) -> CorrectnessResult<Self> {
153 let total = Money::from_decimal(total, currency)?;
154 let free = Money::from_decimal(free, currency)?;
155 let free_raw = if total.raw >= 0 {
156 free.raw.clamp(0, total.raw)
157 } else {
158 free.raw
159 };
160 let clamped_free = Money::from_raw(free_raw, currency);
161 let locked = Money::from_raw(total.raw - clamped_free.raw, currency);
162 Ok(Self::new(total, locked, clamped_free))
163 }
164}
165
166impl PartialEq for AccountBalance {
167 fn eq(&self, other: &Self) -> bool {
168 self.total == other.total && self.locked == other.locked && self.free == other.free
169 }
170}
171
172impl Debug for AccountBalance {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 write!(
175 f,
176 "{}(total={}, locked={}, free={})",
177 stringify!(AccountBalance),
178 self.total,
179 self.locked,
180 self.free,
181 )
182 }
183}
184
185impl Display for AccountBalance {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 write!(f, "{self:?}")
188 }
189}
190
191#[derive(Copy, Clone, Serialize, Deserialize)]
192#[cfg_attr(
193 feature = "python",
194 pyo3::pyclass(
195 module = "nautilus_trader.core.nautilus_pyo3.model",
196 frozen,
197 eq,
198 from_py_object
199 )
200)]
201#[cfg_attr(
202 feature = "python",
203 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
204)]
205pub struct MarginBalance {
216 pub initial: Money,
217 pub maintenance: Money,
218 pub currency: Currency,
219 pub instrument_id: Option<InstrumentId>,
220}
221
222impl MarginBalance {
223 pub fn new_checked(
233 initial: Money,
234 maintenance: Money,
235 instrument_id: Option<InstrumentId>,
236 ) -> CorrectnessResult<Self> {
237 check_predicate_true(
238 initial.currency == maintenance.currency,
239 &format!(
240 "`initial` currency ({}) != `maintenance` currency ({})",
241 initial.currency, maintenance.currency
242 ),
243 )?;
244 Ok(Self {
245 initial,
246 maintenance,
247 currency: initial.currency,
248 instrument_id,
249 })
250 }
251
252 #[must_use]
258 pub fn new(initial: Money, maintenance: Money, instrument_id: Option<InstrumentId>) -> Self {
259 Self::new_checked(initial, maintenance, instrument_id).expect_display(FAILED)
260 }
261}
262
263impl PartialEq for MarginBalance {
264 fn eq(&self, other: &Self) -> bool {
265 self.initial == other.initial
266 && self.maintenance == other.maintenance
267 && self.instrument_id == other.instrument_id
268 }
269}
270
271impl Debug for MarginBalance {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 match self.instrument_id {
274 Some(id) => write!(
275 f,
276 "{}(initial={}, maintenance={}, instrument_id={})",
277 stringify!(MarginBalance),
278 self.initial,
279 self.maintenance,
280 id,
281 ),
282 None => write!(
283 f,
284 "{}(initial={}, maintenance={}, currency={})",
285 stringify!(MarginBalance),
286 self.initial,
287 self.maintenance,
288 self.currency,
289 ),
290 }
291 }
292}
293
294impl Display for MarginBalance {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 write!(f, "{self:?}")
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use rstest::rstest;
303 use rust_decimal::Decimal;
304 use rust_decimal_macros::dec;
305
306 use crate::{
307 identifiers::InstrumentId,
308 types::{
309 AccountBalance, Currency, MarginBalance, Money,
310 stubs::{stub_account_balance, stub_margin_balance},
311 },
312 };
313
314 #[rstest]
315 fn test_account_balance_equality() {
316 let account_balance_1 = stub_account_balance();
317 let account_balance_2 = stub_account_balance();
318 assert_eq!(account_balance_1, account_balance_2);
319 }
320
321 #[rstest]
322 fn test_account_balance_debug(stub_account_balance: AccountBalance) {
323 let result = format!("{stub_account_balance:?}");
324 let expected =
325 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
326 assert_eq!(result, expected);
327 }
328
329 #[rstest]
330 fn test_account_balance_display(stub_account_balance: AccountBalance) {
331 let result = format!("{stub_account_balance}");
332 let expected =
333 "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
334 assert_eq!(result, expected);
335 }
336
337 #[rstest]
338 fn test_account_balance_new_checked_with_currency_mismatch_returns_error() {
339 let usd = Currency::USD();
340 let eur = Currency::EUR();
341 let result = AccountBalance::new_checked(
342 Money::new(1000.0, usd),
343 Money::new(250.0, eur),
344 Money::new(750.0, usd),
345 );
346 assert!(result.is_err());
347 }
348
349 #[rstest]
350 #[should_panic(expected = "`total` currency (USD) != `locked` currency (EUR)")]
351 fn test_account_balance_new_with_currency_mismatch_panics() {
352 let usd = Currency::USD();
353 let eur = Currency::EUR();
354 let _ = AccountBalance::new(
355 Money::new(1000.0, usd),
356 Money::new(250.0, eur),
357 Money::new(750.0, usd),
358 );
359 }
360
361 fn parse_dec(s: &str) -> Decimal {
362 s.parse().unwrap()
363 }
364
365 #[rstest]
366 #[case::zero_zero_usd("0", "0")]
367 #[case::total_zero_positive_locked_usd("0", "5")]
368 #[case::round_usd("1000", "250")]
369 #[case::free_is_zero_usd("1000", "1000")]
370 #[case::locked_is_zero_usd("1000", "0")]
371 #[case::fractional_usd("1234.56", "789.01")]
372 #[case::fractional_btc("10.12345678", "2.87654321")]
373 #[case::small_btc("0.00000001", "0")]
374 #[case::large_usd("1000000000.00", "123.45")]
375 #[case::drift_af_btc("10.000000035", "10.000000031")]
376 #[case::drift_locked_over_precision_btc("10.000000034999", "0.000000004999")]
377 #[case::locked_above_total_usd("100", "150")]
378 #[case::locked_above_total_btc("1.50000000", "5.00000000")]
379 #[case::negative_locked_usd("100", "-5")]
380 #[case::negative_locked_btc("0.50000000", "-0.00000001")]
381 #[case::negative_total_with_reserved("-10", "5")]
382 #[case::negative_total_negative_locked("-10", "-5")]
383 #[case::deep_underwater_with_reserved("-100", "50")]
384 fn test_from_total_and_locked_preserves_invariant(
385 #[case] total_str: &str,
386 #[case] locked_str: &str,
387 ) {
388 for currency in [Currency::USD(), Currency::BTC()] {
389 let total = parse_dec(total_str);
390 let locked = parse_dec(locked_str);
391 let balance = AccountBalance::from_total_and_locked(total, locked, currency).unwrap();
392
393 assert_eq!(
394 balance.total.raw,
395 balance.locked.raw + balance.free.raw,
396 "invariant violated for total={total}, locked={locked}, currency={}",
397 currency.code,
398 );
399 if balance.total.raw >= 0 {
402 assert!(
403 balance.locked.raw >= 0,
404 "locked must be non-negative for non-negative total (found raw={})",
405 balance.locked.raw,
406 );
407 }
408 assert_eq!(balance.total.currency, currency);
409 assert_eq!(balance.locked.currency, currency);
410 assert_eq!(balance.free.currency, currency);
411 }
412 }
413
414 #[rstest]
415 #[case::zero_zero_usd("0", "0")]
416 #[case::round_usd("1000", "750")]
417 #[case::free_equals_total_usd("1000", "1000")]
418 #[case::free_is_zero_usd("1000", "0")]
419 #[case::fractional_usd("1234.56", "444.55")]
420 #[case::fractional_btc("10.12345678", "7.24691356")]
421 #[case::drift_over_precision_btc("10.000000034999", "9.999999994999")]
422 #[case::free_above_total_usd("100", "120")]
423 #[case::free_above_total_btc("0.50000000", "0.99999999")]
424 #[case::negative_free_usd("100", "-5")]
425 #[case::negative_total_usd("-10", "0")]
426 #[case::negative_total_positive_free("-10", "5")]
427 fn test_from_total_and_free_preserves_invariant(
428 #[case] total_str: &str,
429 #[case] free_str: &str,
430 ) {
431 for currency in [Currency::USD(), Currency::BTC()] {
432 let total = parse_dec(total_str);
433 let free = parse_dec(free_str);
434 let balance = AccountBalance::from_total_and_free(total, free, currency).unwrap();
435
436 assert_eq!(
437 balance.total.raw,
438 balance.locked.raw + balance.free.raw,
439 "invariant violated for total={total}, free={free}, currency={}",
440 currency.code,
441 );
442
443 if balance.total.raw >= 0 {
444 assert!(
445 balance.free.raw >= 0,
446 "free must be non-negative for non-negative total (found raw={})",
447 balance.free.raw,
448 );
449 }
450 assert_eq!(balance.total.currency, currency);
451 assert_eq!(balance.locked.currency, currency);
452 assert_eq!(balance.free.currency, currency);
453 }
454 }
455
456 #[rstest]
457 #[case::usd_basic(dec!(1000.00), dec!(250.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
458 #[case::usd_all_free(dec!(500.00), dec!(0.00), dec!(500.00), dec!(0.00), dec!(500.00))]
459 #[case::usd_all_locked(dec!(500.00), dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00))]
460 #[case::usd_clamp_above(dec!(100.00), dec!(150.00), dec!(100.00), dec!(100.00), dec!(0.00))]
461 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(0.00), dec!(100.00))]
462 fn test_from_total_and_locked_exact_usd(
463 #[case] total_in: Decimal,
464 #[case] locked_in: Decimal,
465 #[case] expected_total: Decimal,
466 #[case] expected_locked: Decimal,
467 #[case] expected_free: Decimal,
468 ) {
469 let usd = Currency::USD();
470 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
471
472 assert_eq!(
473 balance.total,
474 Money::from_decimal(expected_total, usd).unwrap()
475 );
476 assert_eq!(
477 balance.locked,
478 Money::from_decimal(expected_locked, usd).unwrap()
479 );
480 assert_eq!(
481 balance.free,
482 Money::from_decimal(expected_free, usd).unwrap()
483 );
484 }
485
486 #[rstest]
487 #[case::usd_basic(dec!(1000.00), dec!(750.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
488 #[case::usd_all_free(dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00), dec!(500.00))]
489 #[case::usd_all_locked(dec!(500.00), dec!(0.00), dec!(500.00), dec!(500.00), dec!(0.00))]
490 #[case::usd_clamp_above(dec!(100.00), dec!(120.00), dec!(100.00), dec!(0.00), dec!(100.00))]
491 #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(100.00), dec!(0.00))]
492 fn test_from_total_and_free_exact_usd(
493 #[case] total_in: Decimal,
494 #[case] free_in: Decimal,
495 #[case] expected_total: Decimal,
496 #[case] expected_locked: Decimal,
497 #[case] expected_free: Decimal,
498 ) {
499 let usd = Currency::USD();
500 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
501
502 assert_eq!(
503 balance.total,
504 Money::from_decimal(expected_total, usd).unwrap()
505 );
506 assert_eq!(
507 balance.locked,
508 Money::from_decimal(expected_locked, usd).unwrap()
509 );
510 assert_eq!(
511 balance.free,
512 Money::from_decimal(expected_free, usd).unwrap()
513 );
514 }
515
516 #[rstest]
520 fn test_from_total_and_locked_issue_3867_drift() {
521 let btc = Currency::BTC();
522 let af = parse_dec("0.000000035");
523 let amount = parse_dec("10") + af;
524 let locked = amount - af;
525
526 let balance = AccountBalance::from_total_and_locked(amount, locked, btc).unwrap();
527
528 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
529 }
530
531 #[rstest]
532 #[case(dec!(0), dec!(100))]
533 #[case(dec!(1), dec!(1000000))]
534 #[case(dec!(500), dec!(500000))]
535 fn test_from_total_and_locked_non_negative_total_never_leaves_free_negative(
536 #[case] total: Decimal,
537 #[case] locked: Decimal,
538 ) {
539 let usd = Currency::USD();
540 let balance = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
541 assert!(
542 balance.free.raw >= 0,
543 "free went negative: total={total}, locked={locked}"
544 );
545 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
546 }
547
548 #[rstest]
549 #[case(dec!(1000.00), dec!(250.00), dec!(750.00))]
550 #[case(dec!(0.00), dec!(0.00), dec!(0.00))]
551 #[case(dec!(500.00), dec!(500.00), dec!(0.00))]
552 #[case(dec!(500.00), dec!(0.00), dec!(500.00))]
553 fn test_locked_and_free_forms_agree_when_consistent(
554 #[case] total: Decimal,
555 #[case] locked: Decimal,
556 #[case] free: Decimal,
557 ) {
558 let usd = Currency::USD();
559 let from_locked = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
560 let from_free = AccountBalance::from_total_and_free(total, free, usd).unwrap();
561 assert_eq!(from_locked, from_free);
562 }
563
564 #[rstest]
565 #[case::borrow_deficit(dec!(-100), dec!(50), dec!(-100), dec!(50), dec!(-150))]
566 #[case::underwater_no_reserve(dec!(-10), dec!(0), dec!(-10), dec!(0), dec!(-10))]
567 #[case::negative_locked_passed_through(dec!(-10), dec!(-5), dec!(-10), dec!(-5), dec!(-5))]
568 fn test_from_total_and_locked_preserves_reserved_on_negative_total(
569 #[case] total_in: Decimal,
570 #[case] locked_in: Decimal,
571 #[case] expected_total: Decimal,
572 #[case] expected_locked: Decimal,
573 #[case] expected_free: Decimal,
574 ) {
575 let usd = Currency::USD();
576 let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
577
578 assert_eq!(
579 balance.total,
580 Money::from_decimal(expected_total, usd).unwrap()
581 );
582 assert_eq!(
583 balance.locked,
584 Money::from_decimal(expected_locked, usd).unwrap()
585 );
586 assert_eq!(
587 balance.free,
588 Money::from_decimal(expected_free, usd).unwrap()
589 );
590 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
591 }
592
593 #[rstest]
594 #[case::available_below_total(dec!(-100), dec!(-150), dec!(-100), dec!(50), dec!(-150))]
595 #[case::available_zero_preserved(dec!(-100), dec!(0), dec!(-100), dec!(-100), dec!(0))]
596 fn test_from_total_and_free_preserves_available_on_negative_total(
597 #[case] total_in: Decimal,
598 #[case] free_in: Decimal,
599 #[case] expected_total: Decimal,
600 #[case] expected_locked: Decimal,
601 #[case] expected_free: Decimal,
602 ) {
603 let usd = Currency::USD();
604 let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
605
606 assert_eq!(
607 balance.total,
608 Money::from_decimal(expected_total, usd).unwrap()
609 );
610 assert_eq!(
611 balance.locked,
612 Money::from_decimal(expected_locked, usd).unwrap()
613 );
614 assert_eq!(
615 balance.free,
616 Money::from_decimal(expected_free, usd).unwrap()
617 );
618 assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
619 }
620
621 #[rstest]
622 fn test_from_total_and_locked_invalid_decimal_returns_error() {
623 let btc = Currency::BTC();
624 let too_large: Decimal = "79228162514264337593543950335".parse().unwrap();
627 let result = AccountBalance::from_total_and_locked(too_large, dec!(0), btc);
628 assert!(result.is_err());
629 }
630
631 #[rstest]
632 fn test_margin_balance_equality() {
633 let margin_balance_1 = stub_margin_balance();
634 let margin_balance_2 = stub_margin_balance();
635 assert_eq!(margin_balance_1, margin_balance_2);
636 }
637
638 #[rstest]
639 fn test_margin_balance_debug(stub_margin_balance: MarginBalance) {
640 let display = format!("{stub_margin_balance:?}");
641 assert_eq!(
642 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
643 display
644 );
645 }
646
647 #[rstest]
648 fn test_margin_balance_display(stub_margin_balance: MarginBalance) {
649 let display = format!("{stub_margin_balance}");
650 assert_eq!(
651 "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
652 display
653 );
654 }
655
656 #[rstest]
657 fn test_margin_balance_new_checked_with_currency_mismatch_returns_error() {
658 let usd = Currency::USD();
659 let eur = Currency::EUR();
660 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
661 let result = MarginBalance::new_checked(
662 Money::new(5000.0, usd),
663 Money::new(20000.0, eur),
664 Some(instrument_id),
665 );
666 assert!(result.is_err());
667 }
668
669 #[rstest]
670 #[should_panic(expected = "`initial` currency (USD) != `maintenance` currency (EUR)")]
671 fn test_margin_balance_new_with_currency_mismatch_panics() {
672 let usd = Currency::USD();
673 let eur = Currency::EUR();
674 let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
675 let _ = MarginBalance::new(
676 Money::new(5000.0, usd),
677 Money::new(20000.0, eur),
678 Some(instrument_id),
679 );
680 }
681
682 #[rstest]
683 fn test_margin_balance_account_scope_display() {
684 let usd = Currency::USD();
685 let balance = MarginBalance::new(Money::new(500.0, usd), Money::new(200.0, usd), None);
686 assert_eq!(
687 "MarginBalance(initial=500.00 USD, maintenance=200.00 USD, currency=USD)",
688 format!("{balance}")
689 );
690 }
691}