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