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    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            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    /// 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.
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 = Money::from_raw(total.raw - clamped_locked.raw, currency);
132        Ok(Self::new(total, clamped_locked, free))
133    }
134
135    /// Creates a new [`AccountBalance`] from `total` and `free` decimal amounts,
136    /// deriving `locked` in fixed-point so the `total == locked + free` invariant
137    /// holds by construction at the currency precision.
138    ///
139    /// When `total` is non-negative, `free` is clamped into `[0, total]` so
140    /// a transient PnL overshoot cannot leave `locked` negative. When `total` is
141    /// negative, `free` is passed through verbatim so the venue-reported available
142    /// amount is preserved and `locked` carries the difference.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if `total` or `free` cannot be represented at the currency
147    /// precision.
148    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)]
205/// Represents a margin balance.
206///
207/// Margin entries have two mutually exclusive scopes:
208///
209/// - Per-instrument: `instrument_id = Some(id)`. Used for isolated margin and
210///   for calculated margin in backtest mode where each instrument carries its
211///   own reserve.
212/// - Account-wide (cross margin): `instrument_id = None`. Used for venues that
213///   report a single aggregate margin per collateral currency (most derivatives
214///   venues in cross-margin mode).
215pub 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    /// Creates a new [`MarginBalance`] instance with correctness checking.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if `initial` and `maintenance` have different currencies.
228    ///
229    /// # Notes
230    ///
231    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
232    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    /// Creates a new [`MarginBalance`] instance.
253    ///
254    /// # Panics
255    ///
256    /// Panics if `initial` and `maintenance` have different currencies.
257    #[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            // When total is non-negative, locked must also be non-negative; when total is
400            // negative the helper passes venue values through so locked may be negative too.
401            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    // Reproducer for issue #3867: three independent `Money::new` calls at currency
517    // precision 8 rounded `(total, locked=amount-af, free=af)` to `1_000_000_003`,
518    // `1_000_000_000`, `4` respectively, violating `total == locked + free`.
519    #[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        // 28 leading digits scaled to BTC precision 8 exceeds MoneyRaw bounds, so
625        // `Money::from_decimal` rejects it and the error propagates.
626        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}