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