use std::fmt::{Debug, Display};
use nautilus_core::correctness::{
CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_predicate_true,
};
use rust_decimal::Decimal;
use serde::{
Deserialize, Deserializer, Serialize, Serializer,
de::IgnoredAny,
ser::{SerializeSeq, SerializeStruct},
};
use crate::{
enums::CurrencyType,
identifiers::InstrumentId,
types::{Currency, Money, fixed::FIXED_PRECISION, money::MoneyRaw},
};
#[derive(Copy, Clone, Serialize)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct AccountBalance {
pub currency: Currency,
pub total: Money,
pub locked: Money,
pub free: Money,
}
impl AccountBalance {
pub fn new_checked(total: Money, locked: Money, free: Money) -> CorrectnessResult<Self> {
check_predicate_true(
total.currency == locked.currency,
&format!(
"`total` currency ({}) != `locked` currency ({})",
total.currency, locked.currency
),
)?;
check_predicate_true(
total.currency == free.currency,
&format!(
"`total` currency ({}) != `free` currency ({})",
total.currency, free.currency
),
)?;
check_predicate_true(
locked.checked_add(free) == Some(total),
&format!("`total` ({total}) - `locked` ({locked}) != `free` ({free})"),
)?;
Ok(Self {
currency: total.currency,
total,
locked,
free,
})
}
#[must_use]
pub fn new(total: Money, locked: Money, free: Money) -> Self {
Self::new_checked(total, locked, free).expect_display(FAILED)
}
pub fn from_total_and_locked(
total: Decimal,
locked: Decimal,
currency: Currency,
) -> CorrectnessResult<Self> {
let total = Money::from_decimal(total, currency)?;
let locked = Money::from_decimal(locked, currency)?;
let locked_raw = if total.raw >= 0 {
locked.raw.clamp(0, total.raw)
} else {
locked.raw
};
let clamped_locked = Money::from_raw(locked_raw, currency);
let free_raw = total.raw.checked_sub(clamped_locked.raw).ok_or_else(|| {
CorrectnessError::PredicateViolation {
message: format!(
"Derived `free` overflows MoneyRaw for `total` {total} and `locked` {clamped_locked}"
),
}
})?;
let free = Money::from_raw_checked(free_raw, currency)?;
Ok(Self::new(total, clamped_locked, free))
}
pub fn from_total_and_free(
total: Decimal,
free: Decimal,
currency: Currency,
) -> CorrectnessResult<Self> {
let total = Money::from_decimal(total, currency)?;
let free = Money::from_decimal(free, currency)?;
let free_raw = if total.raw >= 0 {
free.raw.clamp(0, total.raw)
} else {
free.raw
};
let clamped_free = Money::from_raw(free_raw, currency);
let locked_raw = total.raw.checked_sub(clamped_free.raw).ok_or_else(|| {
CorrectnessError::PredicateViolation {
message: format!(
"Derived `locked` overflows MoneyRaw for `total` {total} and `free` {clamped_free}"
),
}
})?;
let locked = Money::from_raw_checked(locked_raw, currency)?;
Ok(Self::new(total, locked, clamped_free))
}
}
pub(crate) struct WalletAccountBalances<'a> {
balances: &'a [AccountBalance],
}
impl<'a> WalletAccountBalances<'a> {
pub(crate) const fn new(balances: &'a [AccountBalance]) -> Self {
Self { balances }
}
}
impl Serialize for WalletAccountBalances<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut sequence = serializer.serialize_seq(Some(self.balances.len()))?;
for balance in self.balances {
sequence.serialize_element(&WalletAccountBalance(balance))?;
}
sequence.end()
}
}
struct WalletAccountBalance<'a>(&'a AccountBalance);
impl Serialize for WalletAccountBalance<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let balance = self.0;
for money in [balance.total, balance.locked, balance.free] {
if !has_same_currency_identity(balance.currency, money.currency) {
return Err(serde::ser::Error::custom(format!(
"Wallet account balance currency identity {} does not match {money}",
balance.currency
)));
}
}
let mut state = serializer.serialize_struct("AccountBalance", 8)?;
state.serialize_field("currency", &balance.currency)?;
state.serialize_field("total", &balance.total)?;
state.serialize_field("locked", &balance.locked)?;
state.serialize_field("free", &balance.free)?;
state.serialize_field(
"currency_identity",
&CurrencyIdentity::from(balance.currency),
)?;
state.serialize_field(
"total_minor",
&minor_units(balance.total).map_err(serde::ser::Error::custom)?,
)?;
state.serialize_field(
"locked_minor",
&minor_units(balance.locked).map_err(serde::ser::Error::custom)?,
)?;
state.serialize_field(
"free_minor",
&minor_units(balance.free).map_err(serde::ser::Error::custom)?,
)?;
state.end()
}
}
#[derive(Serialize, Deserialize)]
struct CurrencyIdentity {
code: String,
precision: u8,
iso4217: u16,
name: String,
currency_type: CurrencyType,
}
impl From<Currency> for CurrencyIdentity {
fn from(currency: Currency) -> Self {
Self {
code: currency.code.to_string(),
precision: currency.precision,
iso4217: currency.iso4217,
name: currency.name.to_string(),
currency_type: currency.currency_type,
}
}
}
#[derive(Deserialize)]
struct WalletAccountBalanceOwned {
#[serde(rename = "currency")]
_legacy_currency: IgnoredAny,
#[serde(rename = "total")]
_legacy_total: IgnoredAny,
#[serde(rename = "locked")]
_legacy_locked: IgnoredAny,
#[serde(rename = "free")]
_legacy_free: IgnoredAny,
currency_identity: CurrencyIdentity,
total_minor: String,
locked_minor: String,
free_minor: String,
}
#[derive(Deserialize)]
struct AccountBalanceLegacy {
currency: Currency,
total: Money,
locked: Money,
free: Money,
}
impl<'de> Deserialize<'de> for AccountBalance {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value
.as_object()
.is_some_and(|balance| balance.contains_key("currency_identity"))
{
let balance =
WalletAccountBalanceOwned::deserialize(value).map_err(serde::de::Error::custom)?;
let currency = Currency::new_checked(
balance.currency_identity.code,
balance.currency_identity.precision,
balance.currency_identity.iso4217,
balance.currency_identity.name,
balance.currency_identity.currency_type,
)
.map_err(serde::de::Error::custom)?;
let total = money_from_minor_units(&balance.total_minor, currency)
.map_err(serde::de::Error::custom)?;
let locked = money_from_minor_units(&balance.locked_minor, currency)
.map_err(serde::de::Error::custom)?;
let free = money_from_minor_units(&balance.free_minor, currency)
.map_err(serde::de::Error::custom)?;
Self::new_checked(total, locked, free).map_err(serde::de::Error::custom)
} else {
let balance =
AccountBalanceLegacy::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(Self {
currency: balance.currency,
total: balance.total,
locked: balance.locked,
free: balance.free,
})
}
}
}
fn has_same_currency_identity(left: Currency, right: Currency) -> bool {
left.code == right.code
&& left.precision == right.precision
&& left.iso4217 == right.iso4217
&& left.name == right.name
&& left.currency_type == right.currency_type
}
#[allow(
clippy::useless_conversion,
reason = "i128::from narrows MoneyRaw when high-precision is disabled"
)]
fn minor_units(money: Money) -> Result<String, String> {
let scale = 10_i128.pow(u32::from(
FIXED_PRECISION.saturating_sub(money.currency.precision),
));
let raw = i128::from(money.raw);
if raw % scale != 0 {
return Err(format!(
"Wallet money raw value {} is not aligned to currency precision {}",
money.raw, money.currency.precision
));
}
Ok((raw / scale).to_string())
}
#[allow(
clippy::useless_conversion,
reason = "MoneyRaw::try_from narrows i128 when high-precision is disabled"
)]
fn money_from_minor_units(value: &str, currency: Currency) -> Result<Money, String> {
let minor = value
.parse::<i128>()
.map_err(|e| format!("Invalid wallet money minor units '{value}': {e}"))?;
let scale = 10_i128.pow(u32::from(
FIXED_PRECISION.saturating_sub(currency.precision),
));
let raw = minor.checked_mul(scale).ok_or_else(|| {
format!(
"Wallet money minor units {minor} overflow at currency precision {}",
currency.precision
)
})?;
let raw = MoneyRaw::try_from(raw).map_err(|e| {
format!(
"Wallet money minor units {minor} exceed the raw range at currency precision {}: {e}",
currency.precision
)
})?;
Money::from_raw_checked(raw, currency).map_err(|e| e.to_string())
}
impl PartialEq for AccountBalance {
fn eq(&self, other: &Self) -> bool {
self.total == other.total && self.locked == other.locked && self.free == other.free
}
}
impl Debug for AccountBalance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}(total={}, locked={}, free={})",
stringify!(AccountBalance),
self.total,
self.locked,
self.free,
)
}
}
impl Display for AccountBalance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Copy, Clone, Serialize, Deserialize)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct MarginBalance {
pub initial: Money,
pub maintenance: Money,
pub currency: Currency,
pub instrument_id: Option<InstrumentId>,
}
impl MarginBalance {
pub fn new_checked(
initial: Money,
maintenance: Money,
instrument_id: Option<InstrumentId>,
) -> CorrectnessResult<Self> {
check_predicate_true(
initial.currency == maintenance.currency,
&format!(
"`initial` currency ({}) != `maintenance` currency ({})",
initial.currency, maintenance.currency
),
)?;
Ok(Self {
initial,
maintenance,
currency: initial.currency,
instrument_id,
})
}
#[must_use]
pub fn new(initial: Money, maintenance: Money, instrument_id: Option<InstrumentId>) -> Self {
Self::new_checked(initial, maintenance, instrument_id).expect_display(FAILED)
}
}
impl PartialEq for MarginBalance {
fn eq(&self, other: &Self) -> bool {
self.initial == other.initial
&& self.maintenance == other.maintenance
&& self.instrument_id == other.instrument_id
}
}
impl Debug for MarginBalance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.instrument_id {
Some(id) => write!(
f,
"{}(initial={}, maintenance={}, instrument_id={})",
stringify!(MarginBalance),
self.initial,
self.maintenance,
id,
),
None => write!(
f,
"{}(initial={}, maintenance={}, currency={})",
stringify!(MarginBalance),
self.initial,
self.maintenance,
self.currency,
),
}
}
}
impl Display for MarginBalance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use crate::{
identifiers::InstrumentId,
types::{
AccountBalance, Currency, MarginBalance, Money,
stubs::{stub_account_balance, stub_margin_balance},
},
};
#[rstest]
fn test_account_balance_equality() {
let account_balance_1 = stub_account_balance();
let account_balance_2 = stub_account_balance();
assert_eq!(account_balance_1, account_balance_2);
}
#[rstest]
fn test_account_balance_debug(stub_account_balance: AccountBalance) {
let result = format!("{stub_account_balance:?}");
let expected =
"AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
assert_eq!(result, expected);
}
#[rstest]
fn test_account_balance_display(stub_account_balance: AccountBalance) {
let result = format!("{stub_account_balance}");
let expected =
"AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)";
assert_eq!(result, expected);
}
#[rstest]
fn test_account_balance_new_checked_with_currency_mismatch_returns_error() {
let usd = Currency::USD();
let eur = Currency::EUR();
let result = AccountBalance::new_checked(
Money::new(1000.0, usd),
Money::new(250.0, eur),
Money::new(750.0, usd),
);
assert!(result.is_err());
}
#[rstest]
#[should_panic(expected = "`total` currency (USD) != `locked` currency (EUR)")]
fn test_account_balance_new_with_currency_mismatch_panics() {
let usd = Currency::USD();
let eur = Currency::EUR();
let _ = AccountBalance::new(
Money::new(1000.0, usd),
Money::new(250.0, eur),
Money::new(750.0, usd),
);
}
fn parse_dec(s: &str) -> Decimal {
s.parse().unwrap()
}
#[rstest]
#[case::zero_zero_usd("0", "0")]
#[case::total_zero_positive_locked_usd("0", "5")]
#[case::round_usd("1000", "250")]
#[case::free_is_zero_usd("1000", "1000")]
#[case::locked_is_zero_usd("1000", "0")]
#[case::fractional_usd("1234.56", "789.01")]
#[case::fractional_btc("10.12345678", "2.87654321")]
#[case::small_btc("0.00000001", "0")]
#[case::large_usd("1000000000.00", "123.45")]
#[case::drift_af_btc("10.000000035", "10.000000031")]
#[case::drift_locked_over_precision_btc("10.000000034999", "0.000000004999")]
#[case::locked_above_total_usd("100", "150")]
#[case::locked_above_total_btc("1.50000000", "5.00000000")]
#[case::negative_locked_usd("100", "-5")]
#[case::negative_locked_btc("0.50000000", "-0.00000001")]
#[case::negative_total_with_reserved("-10", "5")]
#[case::negative_total_negative_locked("-10", "-5")]
#[case::deep_underwater_with_reserved("-100", "50")]
fn test_from_total_and_locked_preserves_invariant(
#[case] total_str: &str,
#[case] locked_str: &str,
) {
for currency in [Currency::USD(), Currency::BTC()] {
let total = parse_dec(total_str);
let locked = parse_dec(locked_str);
let balance = AccountBalance::from_total_and_locked(total, locked, currency).unwrap();
assert_eq!(
balance.total.raw,
balance.locked.raw + balance.free.raw,
"invariant violated for total={total}, locked={locked}, currency={}",
currency.code,
);
if balance.total.raw >= 0 {
assert!(
balance.locked.raw >= 0,
"locked must be non-negative for non-negative total (found raw={})",
balance.locked.raw,
);
}
assert_eq!(balance.total.currency, currency);
assert_eq!(balance.locked.currency, currency);
assert_eq!(balance.free.currency, currency);
}
}
#[rstest]
#[case::zero_zero_usd("0", "0")]
#[case::round_usd("1000", "750")]
#[case::free_equals_total_usd("1000", "1000")]
#[case::free_is_zero_usd("1000", "0")]
#[case::fractional_usd("1234.56", "444.55")]
#[case::fractional_btc("10.12345678", "7.24691356")]
#[case::drift_over_precision_btc("10.000000034999", "9.999999994999")]
#[case::free_above_total_usd("100", "120")]
#[case::free_above_total_btc("0.50000000", "0.99999999")]
#[case::negative_free_usd("100", "-5")]
#[case::negative_total_usd("-10", "0")]
#[case::negative_total_positive_free("-10", "5")]
fn test_from_total_and_free_preserves_invariant(
#[case] total_str: &str,
#[case] free_str: &str,
) {
for currency in [Currency::USD(), Currency::BTC()] {
let total = parse_dec(total_str);
let free = parse_dec(free_str);
let balance = AccountBalance::from_total_and_free(total, free, currency).unwrap();
assert_eq!(
balance.total.raw,
balance.locked.raw + balance.free.raw,
"invariant violated for total={total}, free={free}, currency={}",
currency.code,
);
if balance.total.raw >= 0 {
assert!(
balance.free.raw >= 0,
"free must be non-negative for non-negative total (found raw={})",
balance.free.raw,
);
}
assert_eq!(balance.total.currency, currency);
assert_eq!(balance.locked.currency, currency);
assert_eq!(balance.free.currency, currency);
}
}
#[rstest]
#[case::usd_basic(dec!(1000.00), dec!(250.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
#[case::usd_all_free(dec!(500.00), dec!(0.00), dec!(500.00), dec!(0.00), dec!(500.00))]
#[case::usd_all_locked(dec!(500.00), dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00))]
#[case::usd_clamp_above(dec!(100.00), dec!(150.00), dec!(100.00), dec!(100.00), dec!(0.00))]
#[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(0.00), dec!(100.00))]
fn test_from_total_and_locked_exact_usd(
#[case] total_in: Decimal,
#[case] locked_in: Decimal,
#[case] expected_total: Decimal,
#[case] expected_locked: Decimal,
#[case] expected_free: Decimal,
) {
let usd = Currency::USD();
let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
assert_eq!(
balance.total,
Money::from_decimal(expected_total, usd).unwrap()
);
assert_eq!(
balance.locked,
Money::from_decimal(expected_locked, usd).unwrap()
);
assert_eq!(
balance.free,
Money::from_decimal(expected_free, usd).unwrap()
);
}
#[rstest]
#[case::usd_basic(dec!(1000.00), dec!(750.00), dec!(1000.00), dec!(250.00), dec!(750.00))]
#[case::usd_all_free(dec!(500.00), dec!(500.00), dec!(500.00), dec!(0.00), dec!(500.00))]
#[case::usd_all_locked(dec!(500.00), dec!(0.00), dec!(500.00), dec!(500.00), dec!(0.00))]
#[case::usd_clamp_above(dec!(100.00), dec!(120.00), dec!(100.00), dec!(0.00), dec!(100.00))]
#[case::usd_clamp_negative(dec!(100.00), dec!(-5.00), dec!(100.00), dec!(100.00), dec!(0.00))]
fn test_from_total_and_free_exact_usd(
#[case] total_in: Decimal,
#[case] free_in: Decimal,
#[case] expected_total: Decimal,
#[case] expected_locked: Decimal,
#[case] expected_free: Decimal,
) {
let usd = Currency::USD();
let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
assert_eq!(
balance.total,
Money::from_decimal(expected_total, usd).unwrap()
);
assert_eq!(
balance.locked,
Money::from_decimal(expected_locked, usd).unwrap()
);
assert_eq!(
balance.free,
Money::from_decimal(expected_free, usd).unwrap()
);
}
#[rstest]
fn test_from_total_and_locked_issue_3867_drift() {
let btc = Currency::BTC();
let af = parse_dec("0.000000035");
let amount = parse_dec("10") + af;
let locked = amount - af;
let balance = AccountBalance::from_total_and_locked(amount, locked, btc).unwrap();
assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
}
#[rstest]
#[case(dec!(0), dec!(100))]
#[case(dec!(1), dec!(1000000))]
#[case(dec!(500), dec!(500000))]
fn test_from_total_and_locked_non_negative_total_never_leaves_free_negative(
#[case] total: Decimal,
#[case] locked: Decimal,
) {
let usd = Currency::USD();
let balance = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
assert!(
balance.free.raw >= 0,
"free went negative: total={total}, locked={locked}"
);
assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
}
#[rstest]
#[case(dec!(1000.00), dec!(250.00), dec!(750.00))]
#[case(dec!(0.00), dec!(0.00), dec!(0.00))]
#[case(dec!(500.00), dec!(500.00), dec!(0.00))]
#[case(dec!(500.00), dec!(0.00), dec!(500.00))]
fn test_locked_and_free_forms_agree_when_consistent(
#[case] total: Decimal,
#[case] locked: Decimal,
#[case] free: Decimal,
) {
let usd = Currency::USD();
let from_locked = AccountBalance::from_total_and_locked(total, locked, usd).unwrap();
let from_free = AccountBalance::from_total_and_free(total, free, usd).unwrap();
assert_eq!(from_locked, from_free);
}
#[rstest]
#[case::borrow_deficit(dec!(-100), dec!(50), dec!(-100), dec!(50), dec!(-150))]
#[case::underwater_no_reserve(dec!(-10), dec!(0), dec!(-10), dec!(0), dec!(-10))]
#[case::negative_locked_passed_through(dec!(-10), dec!(-5), dec!(-10), dec!(-5), dec!(-5))]
fn test_from_total_and_locked_preserves_reserved_on_negative_total(
#[case] total_in: Decimal,
#[case] locked_in: Decimal,
#[case] expected_total: Decimal,
#[case] expected_locked: Decimal,
#[case] expected_free: Decimal,
) {
let usd = Currency::USD();
let balance = AccountBalance::from_total_and_locked(total_in, locked_in, usd).unwrap();
assert_eq!(
balance.total,
Money::from_decimal(expected_total, usd).unwrap()
);
assert_eq!(
balance.locked,
Money::from_decimal(expected_locked, usd).unwrap()
);
assert_eq!(
balance.free,
Money::from_decimal(expected_free, usd).unwrap()
);
assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
}
#[rstest]
#[case::available_below_total(dec!(-100), dec!(-150), dec!(-100), dec!(50), dec!(-150))]
#[case::available_zero_preserved(dec!(-100), dec!(0), dec!(-100), dec!(-100), dec!(0))]
fn test_from_total_and_free_preserves_available_on_negative_total(
#[case] total_in: Decimal,
#[case] free_in: Decimal,
#[case] expected_total: Decimal,
#[case] expected_locked: Decimal,
#[case] expected_free: Decimal,
) {
let usd = Currency::USD();
let balance = AccountBalance::from_total_and_free(total_in, free_in, usd).unwrap();
assert_eq!(
balance.total,
Money::from_decimal(expected_total, usd).unwrap()
);
assert_eq!(
balance.locked,
Money::from_decimal(expected_locked, usd).unwrap()
);
assert_eq!(
balance.free,
Money::from_decimal(expected_free, usd).unwrap()
);
assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
}
#[rstest]
fn test_from_total_and_locked_invalid_decimal_returns_error() {
let btc = Currency::BTC();
let too_large: Decimal = "79228162514264337593543950335".parse().unwrap();
let result = AccountBalance::from_total_and_locked(too_large, dec!(0), btc);
assert!(result.is_err());
}
#[rstest]
fn test_new_checked_extreme_values_returns_error_without_panicking() {
use crate::types::money::MONEY_MAX;
let usd = Currency::USD();
let max = Money::new(MONEY_MAX, usd);
let error = AccountBalance::new_checked(max, max, max).unwrap_err();
assert!(
error.to_string().contains("`total`"),
"unexpected message: {error}"
);
}
#[rstest]
fn test_from_total_and_locked_extreme_bounds_returns_error() {
use crate::types::money::{MONEY_MAX, MONEY_MIN};
let usd = Currency::USD();
let total = Money::new(MONEY_MIN, usd).as_decimal();
let locked = Money::new(MONEY_MAX, usd).as_decimal();
let error = AccountBalance::from_total_and_locked(total, locked, usd).unwrap_err();
assert!(
error.to_string().contains("Money"),
"unexpected message: {error}"
);
}
#[rstest]
fn test_from_total_and_free_extreme_bounds_returns_error() {
use crate::types::money::{MONEY_MAX, MONEY_MIN};
let usd = Currency::USD();
let total = Money::new(MONEY_MIN, usd).as_decimal();
let free = Money::new(MONEY_MAX, usd).as_decimal();
let error = AccountBalance::from_total_and_free(total, free, usd).unwrap_err();
assert!(
error.to_string().contains("Money"),
"unexpected message: {error}"
);
}
#[rstest]
fn test_margin_balance_equality() {
let margin_balance_1 = stub_margin_balance();
let margin_balance_2 = stub_margin_balance();
assert_eq!(margin_balance_1, margin_balance_2);
}
#[rstest]
fn test_margin_balance_debug(stub_margin_balance: MarginBalance) {
let display = format!("{stub_margin_balance:?}");
assert_eq!(
"MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
display
);
}
#[rstest]
fn test_margin_balance_display(stub_margin_balance: MarginBalance) {
let display = format!("{stub_margin_balance}");
assert_eq!(
"MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)",
display
);
}
#[rstest]
fn test_margin_balance_new_checked_with_currency_mismatch_returns_error() {
let usd = Currency::USD();
let eur = Currency::EUR();
let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
let result = MarginBalance::new_checked(
Money::new(5000.0, usd),
Money::new(20000.0, eur),
Some(instrument_id),
);
assert!(result.is_err());
}
#[rstest]
#[should_panic(expected = "`initial` currency (USD) != `maintenance` currency (EUR)")]
fn test_margin_balance_new_with_currency_mismatch_panics() {
let usd = Currency::USD();
let eur = Currency::EUR();
let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
let _ = MarginBalance::new(
Money::new(5000.0, usd),
Money::new(20000.0, eur),
Some(instrument_id),
);
}
#[rstest]
fn test_margin_balance_account_scope_display() {
let usd = Currency::USD();
let balance = MarginBalance::new(Money::new(500.0, usd), Money::new(200.0, usd), None);
assert_eq!(
"MarginBalance(initial=500.00 USD, maintenance=200.00 USD, currency=USD)",
format!("{balance}")
);
}
}