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,
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        // Inline checks keep message formatting off this per-update hot path
69        if total.currency != locked.currency {
70            return Err(CorrectnessError::PredicateViolation {
71                message: format!(
72                    "`total` currency ({}) != `locked` currency ({})",
73                    total.currency, locked.currency
74                ),
75            });
76        }
77
78        if total.currency != free.currency {
79            return Err(CorrectnessError::PredicateViolation {
80                message: format!(
81                    "`total` currency ({}) != `free` currency ({})",
82                    total.currency, free.currency
83                ),
84            });
85        }
86
87        if locked.checked_add(free) != Some(total) {
88            return Err(CorrectnessError::PredicateViolation {
89                message: format!("`total` ({total}) - `locked` ({locked}) != `free` ({free})"),
90            });
91        }
92
93        Ok(Self {
94            currency: total.currency,
95            total,
96            locked,
97            free,
98        })
99    }
100
101    /// Creates a new [`AccountBalance`] instance.
102    ///
103    /// # Panics
104    ///
105    /// Panics if a correctness check fails. See [`AccountBalance::new_checked`] for more details.
106    #[must_use]
107    pub fn new(total: Money, locked: Money, free: Money) -> Self {
108        Self::new_checked(total, locked, free).expect_display(FAILED)
109    }
110
111    /// Creates a new [`AccountBalance`] from `total` and `locked` decimal amounts,
112    /// deriving `free` in fixed-point so the `total == locked + free` invariant
113    /// holds by construction at the currency precision.
114    ///
115    /// When `total` is non-negative, `locked` is clamped into `[0, total]` so
116    /// a transient rounding glitch or overshoot cannot leave `free` negative.
117    /// When `total` is negative (spot borrow deficit or underwater margin account),
118    /// `locked` is passed through verbatim so venue-reported reserved margin is
119    /// preserved and `free` carries the shortfall.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if `total` or `locked` cannot be represented at the currency
124    /// precision, or if the derived `free` amount falls outside the representable range.
125    pub fn from_total_and_locked(
126        total: Decimal,
127        locked: Decimal,
128        currency: Currency,
129    ) -> CorrectnessResult<Self> {
130        let total = Money::from_decimal(total, currency)?;
131        let locked = Money::from_decimal(locked, currency)?;
132
133        Self::from_money_total_and_locked(total, locked)
134    }
135
136    /// Creates a new [`AccountBalance`] from `total` and `free` decimal amounts,
137    /// deriving `locked` in fixed-point so the `total == locked + free` invariant
138    /// holds by construction at the currency precision.
139    ///
140    /// When `total` is non-negative, `free` is clamped into `[0, total]` so
141    /// a transient PnL overshoot cannot leave `locked` negative. When `total` is
142    /// negative, `free` is passed through verbatim so the venue-reported available
143    /// amount is preserved and `locked` carries the difference.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if `total` or `free` cannot be represented at the currency
148    /// precision, or if the derived `locked` amount falls outside the representable range.
149    pub fn from_total_and_free(
150        total: Decimal,
151        free: Decimal,
152        currency: Currency,
153    ) -> CorrectnessResult<Self> {
154        let total = Money::from_decimal(total, currency)?;
155        let free = Money::from_decimal(free, currency)?;
156
157        let clamped_free = if total.is_negative() {
158            free
159        } else {
160            free.clamp(Money::zero(currency), total)
161        };
162
163        let locked = total.checked_sub(clamped_free).ok_or_else(|| {
164            CorrectnessError::PredicateViolation {
165                message: format!(
166                    "Derived `locked` exceeds Money bounds for `total` {total} and `free` {clamped_free}"
167                ),
168            }
169        })?;
170
171        Ok(Self::new(total, locked, clamped_free))
172    }
173
174    /// Creates a balance from native money amounts and derives `free` without rounding.
175    ///
176    /// Applies the same clamping rules as [`Self::from_total_and_locked`].
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if currencies differ or the derived `free` balance exceeds money bounds.
181    pub fn from_money_total_and_locked(total: Money, locked: Money) -> CorrectnessResult<Self> {
182        if total.currency != locked.currency {
183            return Err(CorrectnessError::PredicateViolation {
184                message: format!(
185                    "`total` currency ({}) != `locked` currency ({})",
186                    total.currency, locked.currency
187                ),
188            });
189        }
190
191        let currency = total.currency;
192
193        let clamped_locked = if total.is_negative() {
194            locked
195        } else {
196            locked.clamp(Money::zero(currency), total)
197        };
198
199        let free = total.checked_sub(clamped_locked).ok_or_else(|| {
200            CorrectnessError::PredicateViolation {
201                message: format!(
202                    "Derived `free` exceeds Money bounds for `total` {total} and `locked` {clamped_locked}"
203                ),
204            }
205        })?;
206
207        Self::new_checked(total, clamped_locked, free)
208    }
209}
210
211pub(crate) struct WalletAccountBalances<'a> {
212    balances: &'a [AccountBalance],
213}
214
215impl<'a> WalletAccountBalances<'a> {
216    pub(crate) const fn new(balances: &'a [AccountBalance]) -> Self {
217        Self { balances }
218    }
219}
220
221impl Serialize for WalletAccountBalances<'_> {
222    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
223    where
224        S: Serializer,
225    {
226        let mut sequence = serializer.serialize_seq(Some(self.balances.len()))?;
227        for balance in self.balances {
228            sequence.serialize_element(&WalletAccountBalance(balance))?;
229        }
230        sequence.end()
231    }
232}
233
234struct WalletAccountBalance<'a>(&'a AccountBalance);
235
236impl Serialize for WalletAccountBalance<'_> {
237    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
238    where
239        S: Serializer,
240    {
241        let balance = self.0;
242        for money in [balance.total, balance.locked, balance.free] {
243            if !has_same_currency_identity(balance.currency, money.currency) {
244                return Err(serde::ser::Error::custom(format!(
245                    "Wallet account balance currency identity {} does not match {money}",
246                    balance.currency
247                )));
248            }
249        }
250
251        let mut state = serializer.serialize_struct("AccountBalance", 8)?;
252        state.serialize_field("currency", &balance.currency)?;
253        state.serialize_field("total", &balance.total)?;
254        state.serialize_field("locked", &balance.locked)?;
255        state.serialize_field("free", &balance.free)?;
256        state.serialize_field(
257            "currency_identity",
258            &CurrencyIdentity::from(balance.currency),
259        )?;
260        state.serialize_field(
261            "total_minor",
262            &minor_units(balance.total).map_err(serde::ser::Error::custom)?,
263        )?;
264        state.serialize_field(
265            "locked_minor",
266            &minor_units(balance.locked).map_err(serde::ser::Error::custom)?,
267        )?;
268        state.serialize_field(
269            "free_minor",
270            &minor_units(balance.free).map_err(serde::ser::Error::custom)?,
271        )?;
272        state.end()
273    }
274}
275
276#[derive(Serialize, Deserialize)]
277struct CurrencyIdentity {
278    code: String,
279    precision: u8,
280    iso4217: u16,
281    name: String,
282    currency_type: CurrencyType,
283}
284
285impl From<Currency> for CurrencyIdentity {
286    fn from(currency: Currency) -> Self {
287        Self {
288            code: currency.code.to_string(),
289            precision: currency.precision,
290            iso4217: currency.iso4217,
291            name: currency.name.to_string(),
292            currency_type: currency.currency_type,
293        }
294    }
295}
296
297#[derive(Deserialize)]
298struct WalletAccountBalanceOwned {
299    #[serde(rename = "currency")]
300    _legacy_currency: IgnoredAny,
301    #[serde(rename = "total")]
302    _legacy_total: IgnoredAny,
303    #[serde(rename = "locked")]
304    _legacy_locked: IgnoredAny,
305    #[serde(rename = "free")]
306    _legacy_free: IgnoredAny,
307    currency_identity: CurrencyIdentity,
308    total_minor: String,
309    locked_minor: String,
310    free_minor: String,
311}
312
313#[derive(Deserialize)]
314struct AccountBalanceLegacy {
315    currency: Currency,
316    total: Money,
317    locked: Money,
318    free: Money,
319}
320
321impl<'de> Deserialize<'de> for AccountBalance {
322    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
323    where
324        D: Deserializer<'de>,
325    {
326        let value = serde_json::Value::deserialize(deserializer)?;
327        if value
328            .as_object()
329            .is_some_and(|balance| balance.contains_key("currency_identity"))
330        {
331            let balance =
332                WalletAccountBalanceOwned::deserialize(value).map_err(serde::de::Error::custom)?;
333            let currency = Currency::new_checked(
334                balance.currency_identity.code,
335                balance.currency_identity.precision,
336                balance.currency_identity.iso4217,
337                balance.currency_identity.name,
338                balance.currency_identity.currency_type,
339            )
340            .map_err(serde::de::Error::custom)?;
341            let total = money_from_minor_units(&balance.total_minor, currency)
342                .map_err(serde::de::Error::custom)?;
343            let locked = money_from_minor_units(&balance.locked_minor, currency)
344                .map_err(serde::de::Error::custom)?;
345            let free = money_from_minor_units(&balance.free_minor, currency)
346                .map_err(serde::de::Error::custom)?;
347            Self::new_checked(total, locked, free).map_err(serde::de::Error::custom)
348        } else {
349            let balance =
350                AccountBalanceLegacy::deserialize(value).map_err(serde::de::Error::custom)?;
351            Ok(Self {
352                currency: balance.currency,
353                total: balance.total,
354                locked: balance.locked,
355                free: balance.free,
356            })
357        }
358    }
359}
360
361fn has_same_currency_identity(left: Currency, right: Currency) -> bool {
362    left.code == right.code
363        && left.precision == right.precision
364        && left.iso4217 == right.iso4217
365        && left.name == right.name
366        && left.currency_type == right.currency_type
367}
368
369#[allow(
370    clippy::useless_conversion,
371    reason = "i128::from narrows MoneyRaw when high-precision is disabled"
372)]
373fn minor_units(money: Money) -> Result<String, String> {
374    let scale = raw_per_minor(money.currency.precision);
375    let raw = i128::from(money.raw());
376    if raw % scale != 0 {
377        return Err(format!(
378            "Wallet money raw value {} is not aligned to currency precision {}",
379            money.raw(),
380            money.currency.precision
381        ));
382    }
383    Ok((raw / scale).to_string())
384}
385
386#[allow(
387    clippy::useless_conversion,
388    reason = "MoneyRaw::try_from narrows i128 when high-precision is disabled"
389)]
390fn money_from_minor_units(value: &str, currency: Currency) -> Result<Money, String> {
391    let minor = value
392        .parse::<i128>()
393        .map_err(|e| format!("Invalid wallet money minor units '{value}': {e}"))?;
394    let scale = raw_per_minor(currency.precision);
395    let raw = minor.checked_mul(scale).ok_or_else(|| {
396        format!(
397            "Wallet money minor units {minor} overflow at currency precision {}",
398            currency.precision
399        )
400    })?;
401    let raw = MoneyRaw::try_from(raw).map_err(|e| {
402        format!(
403            "Wallet money minor units {minor} exceed the raw range at currency precision {}: {e}",
404            currency.precision
405        )
406    })?;
407    Money::from_raw_checked(raw, currency).map_err(|e| e.to_string())
408}
409
410fn raw_per_minor(precision: u8) -> i128 {
411    10_i128.pow(u32::from(FIXED_PRECISION.saturating_sub(precision)))
412}
413
414impl PartialEq for AccountBalance {
415    fn eq(&self, other: &Self) -> bool {
416        self.total == other.total && self.locked == other.locked && self.free == other.free
417    }
418}
419
420impl Debug for AccountBalance {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        write!(
423            f,
424            "{}(total={}, locked={}, free={})",
425            stringify!(AccountBalance),
426            self.total,
427            self.locked,
428            self.free,
429        )
430    }
431}
432
433impl Display for AccountBalance {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        write!(f, "{self:?}")
436    }
437}
438
439#[derive(Copy, Clone, Serialize, Deserialize)]
440#[cfg_attr(
441    feature = "python",
442    pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, from_py_object)
443)]
444#[cfg_attr(
445    feature = "python",
446    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
447)]
448/// Represents a margin balance.
449///
450/// Margin entries have two mutually exclusive scopes:
451///
452/// - Per-instrument: `instrument_id = Some(id)`. Used for isolated margin and
453///   for calculated margin in backtest mode where each instrument carries its
454///   own reserve.
455/// - Account-wide (cross margin): `instrument_id = None`. Used for venues that
456///   report a single aggregate margin per collateral currency (most derivatives
457///   venues in cross-margin mode).
458pub struct MarginBalance {
459    pub initial: Money,
460    pub maintenance: Money,
461    pub currency: Currency,
462    pub instrument_id: Option<InstrumentId>,
463}
464
465impl MarginBalance {
466    /// Creates a new [`MarginBalance`] instance with correctness checking.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if `initial` and `maintenance` have different currencies.
471    ///
472    /// # Notes
473    ///
474    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
475    pub fn new_checked(
476        initial: Money,
477        maintenance: Money,
478        instrument_id: Option<InstrumentId>,
479    ) -> CorrectnessResult<Self> {
480        if initial.currency != maintenance.currency {
481            return Err(CorrectnessError::PredicateViolation {
482                message: format!(
483                    "`initial` currency ({}) != `maintenance` currency ({})",
484                    initial.currency, maintenance.currency
485                ),
486            });
487        }
488
489        Ok(Self {
490            initial,
491            maintenance,
492            currency: initial.currency,
493            instrument_id,
494        })
495    }
496
497    /// Creates a new [`MarginBalance`] instance.
498    ///
499    /// # Panics
500    ///
501    /// Panics if `initial` and `maintenance` have different currencies.
502    #[must_use]
503    pub fn new(initial: Money, maintenance: Money, instrument_id: Option<InstrumentId>) -> Self {
504        Self::new_checked(initial, maintenance, instrument_id).expect_display(FAILED)
505    }
506}
507
508impl PartialEq for MarginBalance {
509    fn eq(&self, other: &Self) -> bool {
510        self.initial == other.initial
511            && self.maintenance == other.maintenance
512            && self.instrument_id == other.instrument_id
513    }
514}
515
516impl Debug for MarginBalance {
517    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518        match self.instrument_id {
519            Some(id) => write!(
520                f,
521                "{}(initial={}, maintenance={}, instrument_id={})",
522                stringify!(MarginBalance),
523                self.initial,
524                self.maintenance,
525                id,
526            ),
527            None => write!(
528                f,
529                "{}(initial={}, maintenance={}, currency={})",
530                stringify!(MarginBalance),
531                self.initial,
532                self.maintenance,
533                self.currency,
534            ),
535        }
536    }
537}
538
539impl Display for MarginBalance {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        write!(f, "{self:?}")
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use nautilus_core::correctness::CorrectnessError;
548    use rstest::rstest;
549    use rust_decimal::Decimal;
550    use rust_decimal_macros::dec;
551
552    use super::{has_same_currency_identity, money_from_minor_units};
553    use crate::{
554        enums::CurrencyType,
555        identifiers::InstrumentId,
556        types::{
557            AccountBalance, Currency, MarginBalance, Money,
558            stubs::{stub_account_balance, stub_margin_balance},
559        },
560    };
561
562    #[rstest]
563    fn test_has_same_currency_identity_requires_every_field() {
564        let usd = Currency::USD();
565
566        assert!(has_same_currency_identity(
567            usd,
568            Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat)
569        ));
570        assert!(!has_same_currency_identity(
571            usd,
572            Currency::new("XXX", 2, 840, "United States dollar", CurrencyType::Fiat)
573        ));
574        assert!(!has_same_currency_identity(
575            usd,
576            Currency::new("USD", 8, 840, "United States dollar", CurrencyType::Fiat)
577        ));
578        assert!(!has_same_currency_identity(
579            usd,
580            Currency::new("USD", 2, 0, "United States dollar", CurrencyType::Fiat)
581        ));
582        assert!(!has_same_currency_identity(
583            usd,
584            Currency::new("USD", 2, 840, "US dollar", CurrencyType::Fiat)
585        ));
586        assert!(!has_same_currency_identity(
587            usd,
588            Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Crypto)
589        ));
590    }
591
592    #[rstest]
593    fn test_margin_balance_equality_compares_every_field() {
594        let instrument_id = InstrumentId::from("AUD/USD.SIM");
595
596        let balance = MarginBalance::new(
597            Money::from("100 USD"),
598            Money::from("50 USD"),
599            Some(instrument_id),
600        );
601
602        assert_eq!(
603            balance,
604            MarginBalance::new(
605                Money::from("100 USD"),
606                Money::from("50 USD"),
607                Some(instrument_id),
608            )
609        );
610        assert_ne!(
611            balance,
612            MarginBalance::new(
613                Money::from("100 USD"),
614                Money::from("60 USD"),
615                Some(instrument_id),
616            )
617        );
618        assert_ne!(
619            balance,
620            MarginBalance::new(
621                Money::from("200 USD"),
622                Money::from("50 USD"),
623                Some(instrument_id),
624            )
625        );
626        assert_ne!(
627            balance,
628            MarginBalance::new(Money::from("100 USD"), Money::from("50 USD"), None)
629        );
630    }
631
632    #[rstest]
633    fn test_account_balance_equality_compares_every_amount() {
634        let balance = AccountBalance::new(
635            Money::from("100 USD"),
636            Money::from("25 USD"),
637            Money::from("75 USD"),
638        );
639
640        assert_eq!(
641            balance,
642            AccountBalance::new(
643                Money::from("100 USD"),
644                Money::from("25 USD"),
645                Money::from("75 USD"),
646            )
647        );
648        assert_ne!(
649            balance,
650            AccountBalance::new(
651                Money::from("100 USD"),
652                Money::from("50 USD"),
653                Money::from("50 USD"),
654            )
655        );
656        assert_ne!(
657            balance,
658            AccountBalance::new(
659                Money::from("200 USD"),
660                Money::from("125 USD"),
661                Money::from("75 USD"),
662            )
663        );
664    }
665
666    #[rstest]
667    fn test_account_balance_equality() {
668        let account_balance_1 = stub_account_balance();
669        let account_balance_2 = stub_account_balance();
670        assert_eq!(account_balance_1, account_balance_2);
671    }
672
673    #[rstest]
674    fn test_account_balance_debug(stub_account_balance: AccountBalance) {
675        let result = format!("{stub_account_balance:?}");
676        let expected =
677            "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
678        assert_eq!(result, expected);
679    }
680
681    #[rstest]
682    fn test_account_balance_display(stub_account_balance: AccountBalance) {
683        let result = format!("{stub_account_balance}");
684        let expected =
685            "AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
686        assert_eq!(result, expected);
687    }
688
689    #[rstest]
690    #[case::locked(
691        Currency::EUR(),
692        Currency::USD(),
693        "`total` currency (USD) != `locked` currency (EUR)"
694    )]
695    #[case::free(
696        Currency::USD(),
697        Currency::EUR(),
698        "`total` currency (USD) != `free` currency (EUR)"
699    )]
700    fn test_account_balance_new_checked_with_currency_mismatch_returns_error(
701        #[case] locked_currency: Currency,
702        #[case] free_currency: Currency,
703        #[case] message: &str,
704    ) {
705        let usd = Currency::USD();
706        let error = AccountBalance::new_checked(
707            Money::new(1000.0, usd),
708            Money::new(250.0, locked_currency),
709            Money::new(750.0, free_currency),
710        )
711        .unwrap_err();
712
713        assert_eq!(
714            error,
715            CorrectnessError::PredicateViolation {
716                message: message.to_string(),
717            }
718        );
719    }
720
721    #[rstest]
722    #[should_panic(expected = "`total` currency (USD) != `locked` currency (EUR)")]
723    fn test_account_balance_new_with_currency_mismatch_panics() {
724        let usd = Currency::USD();
725        let eur = Currency::EUR();
726        let _ = AccountBalance::new(
727            Money::new(1000.0, usd),
728            Money::new(250.0, eur),
729            Money::new(750.0, usd),
730        );
731    }
732
733    #[rstest]
734    fn test_money_from_minor_units_rejects_invalid_integer() {
735        let error = money_from_minor_units("invalid", Currency::USD()).unwrap_err();
736
737        assert_eq!(
738            error,
739            "Invalid wallet money minor units 'invalid': invalid digit found in string"
740        );
741    }
742
743    #[rstest]
744    fn test_money_from_minor_units_rejects_scaling_overflow() {
745        let value = i128::MAX.to_string();
746        let error = money_from_minor_units(&value, Currency::USD()).unwrap_err();
747
748        assert_eq!(
749            error,
750            format!(
751                "Wallet money minor units {} overflow at currency precision 2",
752                i128::MAX
753            )
754        );
755    }
756
757    fn parse_dec(s: &str) -> Decimal {
758        s.parse().unwrap()
759    }
760
761    #[rstest]
762    #[case::zero_zero_usd("0", "0")]
763    #[case::total_zero_positive_locked_usd("0", "5")]
764    #[case::round_usd("1000", "250")]
765    #[case::free_is_zero_usd("1000", "1000")]
766    #[case::locked_is_zero_usd("1000", "0")]
767    #[case::fractional_usd("1234.56", "789.01")]
768    #[case::fractional_btc("10.12345678", "2.87654321")]
769    #[case::small_btc("0.00000001", "0")]
770    #[case::large_usd("1000000000.00", "123.45")]
771    #[case::drift_af_btc("10.000000035", "10.000000031")]
772    #[case::drift_locked_over_precision_btc("10.000000034999", "0.000000004999")]
773    #[case::locked_above_total_usd("100", "150")]
774    #[case::locked_above_total_btc("1.50000000", "5.00000000")]
775    #[case::negative_locked_usd("100", "-5")]
776    #[case::negative_locked_btc("0.50000000", "-0.00000001")]
777    #[case::negative_total_with_reserved("-10", "5")]
778    #[case::negative_total_negative_locked("-10", "-5")]
779    #[case::deep_underwater_with_reserved("-100", "50")]
780    fn test_from_total_and_locked_preserves_invariant(
781        #[case] total_str: &str,
782        #[case] locked_str: &str,
783    ) {
784        for currency in [Currency::USD(), Currency::BTC()] {
785            let total = parse_dec(total_str);
786            let locked = parse_dec(locked_str);
787            let balance = AccountBalance::from_total_and_locked(total, locked, currency).unwrap();
788
789            assert_eq!(
790                balance.total,
791                balance.locked + balance.free,
792                "invariant violated for total={total}, locked={locked}, currency={}",
793                currency.code,
794            );
795            // When total is non-negative, locked must also be non-negative; when total is
796            // negative the constructor passes venue values through so locked may be negative too.
797            if !balance.total.is_negative() {
798                assert!(
799                    !balance.locked.is_negative(),
800                    "locked must be non-negative for non-negative total (found raw={})",
801                    balance.locked.raw(),
802                );
803            }
804            assert_eq!(balance.total.currency, currency);
805            assert_eq!(balance.locked.currency, currency);
806            assert_eq!(balance.free.currency, currency);
807        }
808    }
809
810    #[rstest]
811    #[case::zero_zero_usd("0", "0")]
812    #[case::round_usd("1000", "750")]
813    #[case::free_equals_total_usd("1000", "1000")]
814    #[case::free_is_zero_usd("1000", "0")]
815    #[case::fractional_usd("1234.56", "444.55")]
816    #[case::fractional_btc("10.12345678", "7.24691356")]
817    #[case::drift_over_precision_btc("10.000000034999", "9.999999994999")]
818    #[case::free_above_total_usd("100", "120")]
819    #[case::free_above_total_btc("0.50000000", "0.99999999")]
820    #[case::negative_free_usd("100", "-5")]
821    #[case::negative_total_usd("-10", "0")]
822    #[case::negative_total_positive_free("-10", "5")]
823    fn test_from_total_and_free_preserves_invariant(
824        #[case] total_str: &str,
825        #[case] free_str: &str,
826    ) {
827        for currency in [Currency::USD(), Currency::BTC()] {
828            let total = parse_dec(total_str);
829            let free = parse_dec(free_str);
830            let balance = AccountBalance::from_total_and_free(total, free, currency).unwrap();
831
832            assert_eq!(
833                balance.total,
834                balance.locked + balance.free,
835                "invariant violated for total={total}, free={free}, currency={}",
836                currency.code,
837            );
838
839            if !balance.total.is_negative() {
840                assert!(
841                    !balance.free.is_negative(),
842                    "free must be non-negative for non-negative total (found raw={})",
843                    balance.free.raw(),
844                );
845            }
846            assert_eq!(balance.total.currency, currency);
847            assert_eq!(balance.locked.currency, currency);
848            assert_eq!(balance.free.currency, currency);
849        }
850    }
851
852    #[rstest]
853    #[case::usd_basic(dec!(1000.00), dec!(250.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
854    #[case::usd_all_free(dec!(500.00), dec!(0.00), dec!(500.00), dec!(0.00), dec!(500.00))]
855    #[case::usd_all_locked(dec!(500.00), dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00))]
856    #[case::usd_clamp_above(dec!(100.00), dec!(150.00), dec!(100.00), dec!(100.00), dec!(0.00))]
857    #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(0.00), dec!(100.00))]
858    fn test_from_total_and_locked_exact_usd(
859        #[case] total_in: Decimal,
860        #[case] locked_in: Decimal,
861        #[case] expected_total: Decimal,
862        #[case] expected_locked: Decimal,
863        #[case] expected_free: Decimal,
864    ) {
865        let usd = Currency::USD();
866        let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
867
868        assert_eq!(
869            balance.total,
870            Money::from_decimal(expected_total, usd).unwrap()
871        );
872        assert_eq!(
873            balance.locked,
874            Money::from_decimal(expected_locked, usd).unwrap()
875        );
876        assert_eq!(
877            balance.free,
878            Money::from_decimal(expected_free, usd).unwrap()
879        );
880    }
881
882    #[rstest]
883    #[case::usd_basic(dec!(1000.00), dec!(750.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
884    #[case::usd_all_free(dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00), dec!(500.00))]
885    #[case::usd_all_locked(dec!(500.00), dec!(0.00), dec!(500.00), dec!(500.00), dec!(0.00))]
886    #[case::usd_clamp_above(dec!(100.00), dec!(120.00), dec!(100.00), dec!(0.00), dec!(100.00))]
887    #[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(100.00), dec!(0.00))]
888    fn test_from_total_and_free_exact_usd(
889        #[case] total_in: Decimal,
890        #[case] free_in: Decimal,
891        #[case] expected_total: Decimal,
892        #[case] expected_locked: Decimal,
893        #[case] expected_free: Decimal,
894    ) {
895        let usd = Currency::USD();
896        let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
897
898        assert_eq!(
899            balance.total,
900            Money::from_decimal(expected_total, usd).unwrap()
901        );
902        assert_eq!(
903            balance.locked,
904            Money::from_decimal(expected_locked, usd).unwrap()
905        );
906        assert_eq!(
907            balance.free,
908            Money::from_decimal(expected_free, usd).unwrap()
909        );
910    }
911
912    // Reproducer for issue #3867: three independent `Money::new` calls at currency
913    // precision 8 rounded `(total, locked=amount-af, free=af)` to `1_000_000_003`,
914    // `1_000_000_000`, `4` respectively, violating `total == locked + free`.
915    #[rstest]
916    fn test_from_total_and_locked_issue_3867_drift() {
917        let btc = Currency::BTC();
918        let af = parse_dec("0.000000035");
919        let amount = parse_dec("10") + af;
920        let locked = amount - af;
921
922        let balance = AccountBalance::from_total_and_locked(amount, locked, btc).unwrap();
923
924        assert_eq!(balance.total, balance.locked + balance.free);
925    }
926
927    #[rstest]
928    #[case(dec!(0), dec!(100))]
929    #[case(dec!(1), dec!(1000000))]
930    #[case(dec!(500), dec!(500000))]
931    fn test_from_total_and_locked_non_negative_total_never_leaves_free_negative(
932        #[case] total: Decimal,
933        #[case] locked: Decimal,
934    ) {
935        let usd = Currency::USD();
936        let balance = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
937        assert!(
938            !balance.free.is_negative(),
939            "free went negative: total={total}, locked={locked}"
940        );
941        assert_eq!(balance.total, balance.locked + balance.free);
942    }
943
944    #[rstest]
945    #[case(dec!(1000.00), dec!(250.00), dec!(750.00))]
946    #[case(dec!(0.00), dec!(0.00), dec!(0.00))]
947    #[case(dec!(500.00), dec!(500.00), dec!(0.00))]
948    #[case(dec!(500.00), dec!(0.00), dec!(500.00))]
949    fn test_locked_and_free_forms_agree_when_consistent(
950        #[case] total: Decimal,
951        #[case] locked: Decimal,
952        #[case] free: Decimal,
953    ) {
954        let usd = Currency::USD();
955        let from_locked = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
956        let from_free = AccountBalance::from_total_and_free(total, free, usd).unwrap();
957        assert_eq!(from_locked, from_free);
958    }
959
960    #[rstest]
961    #[case::borrow_deficit(dec!(-100), dec!(50), dec!(-100), dec!(50), dec!(-150))]
962    #[case::underwater_no_reserve(dec!(-10), dec!(0), dec!(-10), dec!(0), dec!(-10))]
963    #[case::negative_locked_passed_through(dec!(-10), dec!(-5), dec!(-10), dec!(-5), dec!(-5))]
964    fn test_from_total_and_locked_preserves_reserved_on_negative_total(
965        #[case] total_in: Decimal,
966        #[case] locked_in: Decimal,
967        #[case] expected_total: Decimal,
968        #[case] expected_locked: Decimal,
969        #[case] expected_free: Decimal,
970    ) {
971        let usd = Currency::USD();
972        let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
973
974        assert_eq!(
975            balance.total,
976            Money::from_decimal(expected_total, usd).unwrap()
977        );
978        assert_eq!(
979            balance.locked,
980            Money::from_decimal(expected_locked, usd).unwrap()
981        );
982        assert_eq!(
983            balance.free,
984            Money::from_decimal(expected_free, usd).unwrap()
985        );
986        assert_eq!(balance.total, balance.locked + balance.free);
987    }
988
989    #[rstest]
990    #[case::available_below_total(dec!(-100), dec!(-150), dec!(-100), dec!(50), dec!(-150))]
991    #[case::available_zero_preserved(dec!(-100), dec!(0), dec!(-100), dec!(-100), dec!(0))]
992    fn test_from_total_and_free_preserves_available_on_negative_total(
993        #[case] total_in: Decimal,
994        #[case] free_in: Decimal,
995        #[case] expected_total: Decimal,
996        #[case] expected_locked: Decimal,
997        #[case] expected_free: Decimal,
998    ) {
999        let usd = Currency::USD();
1000        let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
1001
1002        assert_eq!(
1003            balance.total,
1004            Money::from_decimal(expected_total, usd).unwrap()
1005        );
1006        assert_eq!(
1007            balance.locked,
1008            Money::from_decimal(expected_locked, usd).unwrap()
1009        );
1010        assert_eq!(
1011            balance.free,
1012            Money::from_decimal(expected_free, usd).unwrap()
1013        );
1014        assert_eq!(balance.total, balance.locked + balance.free);
1015    }
1016
1017    #[rstest]
1018    fn test_from_total_and_locked_invalid_decimal_returns_error() {
1019        let btc = Currency::BTC();
1020        // 28 leading digits scaled to BTC precision 8 exceeds MoneyRaw bounds, so
1021        // `Money::from_decimal` rejects it and the error propagates.
1022        let too_large: Decimal = "79228162514264337593543950335".parse().unwrap();
1023        let result = AccountBalance::from_total_and_locked(too_large, dec!(0), btc);
1024        assert!(result.is_err());
1025    }
1026
1027    #[rstest]
1028    fn test_new_checked_extreme_values_returns_error_without_panicking() {
1029        use crate::types::money::MONEY_MAX;
1030
1031        // The raw sum of two maximum balances exceeds MoneyRaw; the invariant
1032        // check must report an error rather than panicking on overflow.
1033        let usd = Currency::USD();
1034        let max = Money::new(MONEY_MAX, usd);
1035
1036        let error = AccountBalance::new_checked(max, max, max).unwrap_err();
1037        assert!(
1038            error.to_string().contains("`total`"),
1039            "unexpected message: {error}"
1040        );
1041    }
1042
1043    #[rstest]
1044    fn test_from_total_and_locked_extreme_bounds_returns_error() {
1045        use crate::types::money::{MONEY_MAX, MONEY_MIN};
1046
1047        // Deriving free = MIN - MAX overflows MoneyRaw (or falls outside its
1048        // bounds), which must surface as an error rather than a panic.
1049        let usd = Currency::USD();
1050        let total = Money::new(MONEY_MIN, usd).as_decimal();
1051        let locked = Money::new(MONEY_MAX, usd).as_decimal();
1052
1053        let error = AccountBalance::from_total_and_locked(total, locked, usd).unwrap_err();
1054        assert!(
1055            error.to_string().contains("Money"),
1056            "unexpected message: {error}"
1057        );
1058    }
1059
1060    #[rstest]
1061    fn test_from_total_and_free_extreme_bounds_returns_error() {
1062        use crate::types::money::{MONEY_MAX, MONEY_MIN};
1063
1064        let usd = Currency::USD();
1065        let total = Money::new(MONEY_MIN, usd).as_decimal();
1066        let free = Money::new(MONEY_MAX, usd).as_decimal();
1067
1068        let error = AccountBalance::from_total_and_free(total, free, usd).unwrap_err();
1069        assert!(
1070            error.to_string().contains("Money"),
1071            "unexpected message: {error}"
1072        );
1073    }
1074
1075    #[rstest]
1076    fn test_margin_balance_equality() {
1077        let margin_balance_1 = stub_margin_balance();
1078        let margin_balance_2 = stub_margin_balance();
1079        assert_eq!(margin_balance_1, margin_balance_2);
1080    }
1081
1082    #[rstest]
1083    fn test_margin_balance_debug(stub_margin_balance: MarginBalance) {
1084        let display = format!("{stub_margin_balance:?}");
1085        assert_eq!(
1086            "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
1087            display
1088        );
1089    }
1090
1091    #[rstest]
1092    fn test_margin_balance_display(stub_margin_balance: MarginBalance) {
1093        let display = format!("{stub_margin_balance}");
1094        assert_eq!(
1095            "MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
1096            display
1097        );
1098    }
1099
1100    #[rstest]
1101    fn test_margin_balance_new_checked_with_currency_mismatch_returns_error() {
1102        let usd = Currency::USD();
1103        let eur = Currency::EUR();
1104        let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
1105        let result = MarginBalance::new_checked(
1106            Money::new(5000.0, usd),
1107            Money::new(20000.0, eur),
1108            Some(instrument_id),
1109        );
1110        assert!(result.is_err());
1111    }
1112
1113    #[rstest]
1114    #[should_panic(expected = "`initial` currency (USD) != `maintenance` currency (EUR)")]
1115    fn test_margin_balance_new_with_currency_mismatch_panics() {
1116        let usd = Currency::USD();
1117        let eur = Currency::EUR();
1118        let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
1119        let _ = MarginBalance::new(
1120            Money::new(5000.0, usd),
1121            Money::new(20000.0, eur),
1122            Some(instrument_id),
1123        );
1124    }
1125
1126    #[rstest]
1127    fn test_margin_balance_account_scope_display() {
1128        let usd = Currency::USD();
1129        let balance = MarginBalance::new(Money::new(500.0, usd), Money::new(200.0, usd), None);
1130        assert_eq!(
1131            "MarginBalance(initial=500.00 USD, maintenance=200.00 USD, currency=USD)",
1132            format!("{balance}")
1133        );
1134    }
1135}