Skip to main content

nautilus_model/types/
balance.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Represents an account balance denominated in a particular currency.
17
18use 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/// Represents an account balance denominated in a particular currency.
32#[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    /// The account balance currency.
48    pub currency: Currency,
49    /// The total account balance.
50    pub total: Money,
51    /// The account balance locked (assigned to pending orders).
52    pub locked: Money,
53    /// The account balance free for trading.
54    pub free: Money,
55}
56
57impl AccountBalance {
58    /// Creates a new [`AccountBalance`] instance with correctness checking.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if `total` is not the result of `locked` + `free`.
63    ///
64    /// # Notes
65    ///
66    /// PyO3 requires a `Result` type that stacktrace can be printed for errors.
67    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    /// Creates a new [`AccountBalance`] instance.
95    ///
96    /// # Panics
97    ///
98    /// Panics if a correctness check fails. See [`AccountBalance::new_checked`] for more details.
99    #[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    /// Creates a new [`AccountBalance`] from `total` and `locked` decimal amounts,
105    /// deriving `free` in fixed-point so the `total == locked + free` invariant
106    /// holds by construction at the currency precision.
107    ///
108    /// When `total` is non-negative, `locked` is clamped into `[0, total]` so
109    /// a transient rounding glitch or overshoot cannot leave `free` negative.
110    /// When `total` is negative (spot borrow deficit or underwater margin account),
111    /// `locked` is passed through verbatim so venue-reported reserved margin is
112    /// preserved and `free` carries the shortfall.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if `total` or `locked` cannot be represented at the currency
117    /// precision, or if the derived `free` amount falls outside the representable range.
118    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    /// Creates a new [`AccountBalance`] from `total` and `free` decimal amounts,
143    /// deriving `locked` in fixed-point so the `total == locked + free` invariant
144    /// holds by construction at the currency precision.
145    ///
146    /// When `total` is non-negative, `free` is clamped into `[0, total]` so
147    /// a transient PnL overshoot cannot leave `locked` negative. When `total` is
148    /// negative, `free` is passed through verbatim so the venue-reported available
149    /// amount is preserved and `locked` carries the difference.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if `total` or `free` cannot be represented at the currency
154    /// precision, or if the derived `locked` amount falls outside the representable range.
155    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)]
219/// Represents a margin balance.
220///
221/// Margin entries have two mutually exclusive scopes:
222///
223/// - Per-instrument: `instrument_id = Some(id)`. Used for isolated margin and
224///   for calculated margin in backtest mode where each instrument carries its
225///   own reserve.
226/// - Account-wide (cross margin): `instrument_id = None`. Used for venues that
227///   report a single aggregate margin per collateral currency (most derivatives
228///   venues in cross-margin mode).
229pub 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    /// Creates a new [`MarginBalance`] instance with correctness checking.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if `initial` and `maintenance` have different currencies.
242    ///
243    /// # Notes
244    ///
245    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
246    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    /// Creates a new [`MarginBalance`] instance.
267    ///
268    /// # Panics
269    ///
270    /// Panics if `initial` and `maintenance` have different currencies.
271    #[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            // When total is non-negative, locked must also be non-negative; when total is
414            // negative the helper passes venue values through so locked may be negative too.
415            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    // Reproducer for issue #3867: three independent `Money::new` calls at currency
531    // precision 8 rounded `(total, locked=amount-af, free=af)` to `1_000_000_003`,
532    // `1_000_000_000`, `4` respectively, violating `total == locked + free`.
533    #[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        // 28 leading digits scaled to BTC precision 8 exceeds MoneyRaw bounds, so
639        // `Money::from_decimal` rejects it and the error propagates.
640        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        // The raw sum of two maximum balances exceeds MoneyRaw; the invariant
650        // check must report an error rather than panicking on overflow.
651        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        // Deriving free = MIN - MAX overflows MoneyRaw (or falls outside its
666        // bounds), which must surface as an error rather than a panic.
667        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}