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