use std::{borrow::Cow, str::FromStr};
use chrono::{DateTime, Utc};
use nautilus_core::{Params, nanos::UnixNanos, uuid::UUID4};
use nautilus_model::{
data::bar::BarType,
enums::{AccountType, AggressorSide, CurrencyType, LiquiditySide, PositionSide, TriggerType},
events::AccountState,
identifiers::{AccountId, InstrumentId, Symbol},
instruments::{Instrument, InstrumentAny},
types::{
AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
quantity::{QUANTITY_RAW_MAX, QuantityRaw},
},
};
use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
use ustr::Ustr;
use crate::{
common::{
consts::BITMEX_VENUE,
enums::{BitmexExecInstruction, BitmexLiquidityIndicator, BitmexPegPriceType, BitmexSide},
},
websocket::messages::BitmexMarginMsg,
};
#[must_use]
pub fn clean_reason(reason: &str) -> String {
reason.replace("\nNautilusTrader", "").trim().to_string()
}
#[must_use]
pub fn extract_trigger_type(exec_inst: Option<&Vec<BitmexExecInstruction>>) -> TriggerType {
if let Some(exec_insts) = exec_inst {
if exec_insts.contains(&BitmexExecInstruction::MarkPrice) {
TriggerType::MarkPrice
} else if exec_insts.contains(&BitmexExecInstruction::IndexPrice) {
TriggerType::IndexPrice
} else if exec_insts.contains(&BitmexExecInstruction::LastPrice) {
TriggerType::LastPrice
} else {
TriggerType::Default
}
} else {
TriggerType::Default
}
}
#[must_use]
pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *BITMEX_VENUE)
}
#[must_use]
pub fn quantity_to_u32(quantity: &Quantity, instrument: &InstrumentAny) -> u32 {
let size_increment = instrument.size_increment();
let step_decimal = size_increment.as_decimal();
if step_decimal.is_zero() {
let value = quantity.as_f64();
if value > u32::MAX as f64 {
log::warn!("Quantity {value} exceeds u32::MAX without instrument increment, clamping",);
return u32::MAX;
}
return value.max(0.0) as u32;
}
let units_decimal = quantity.as_decimal() / step_decimal;
let rounded_units =
units_decimal.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
match rounded_units.to_u128() {
Some(units) if units <= u32::MAX as u128 => units as u32,
Some(units) => {
log::warn!(
"Quantity {} converts to {units} contracts which exceeds u32::MAX, clamping",
quantity.as_f64(),
);
u32::MAX
}
None => {
log::warn!(
"Failed to convert quantity {} to venue units, defaulting to 0",
quantity.as_f64(),
);
0
}
}
}
#[must_use]
pub fn parse_contracts_quantity(value: u64, instrument: &InstrumentAny) -> Quantity {
let size_increment = instrument.size_increment();
let precision = instrument.size_precision();
let increment_raw: QuantityRaw = (&size_increment).into();
let value_raw = QuantityRaw::from(value);
let mut raw = increment_raw.saturating_mul(value_raw);
if raw > QUANTITY_RAW_MAX {
log::warn!("Quantity value {value} exceeds QUANTITY_RAW_MAX {QUANTITY_RAW_MAX}, clamping",);
raw = QUANTITY_RAW_MAX;
}
Quantity::from_raw(raw, precision)
}
pub fn derive_contract_decimal_and_increment(
multiplier: Option<f64>,
max_scale: u32,
) -> anyhow::Result<(Decimal, Quantity)> {
let raw_multiplier = multiplier.unwrap_or(1.0);
let contract_size = if raw_multiplier > 0.0 {
1.0 / raw_multiplier
} else {
1.0
};
let mut contract_decimal = Decimal::from_str(&contract_size.to_string())
.map_err(|_| anyhow::anyhow!("Invalid contract size {contract_size}"))?;
if contract_decimal.scale() > max_scale {
contract_decimal = contract_decimal
.round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
}
contract_decimal = contract_decimal.normalize();
let contract_precision = contract_decimal.scale() as u8;
let size_increment = Quantity::from_decimal_dp(contract_decimal, contract_precision)?;
Ok((contract_decimal, size_increment))
}
pub fn convert_contract_quantity(
value: Option<f64>,
contract_decimal: Decimal,
max_scale: u32,
field_name: &str,
) -> anyhow::Result<Option<Quantity>> {
value
.map(|raw| {
let mut decimal = Decimal::from_str(&raw.to_string())
.map_err(|_| anyhow::anyhow!("Invalid {field_name} value"))?
* contract_decimal;
let scale = decimal.scale();
if scale > max_scale {
decimal = decimal
.round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
}
let decimal = decimal.normalize();
let precision = decimal.scale() as u8;
Quantity::from_decimal_dp(decimal, precision)
})
.transpose()
}
#[must_use]
pub fn parse_signed_contracts_quantity(value: i64, instrument: &InstrumentAny) -> Quantity {
let abs_value = value.checked_abs().unwrap_or_else(|| {
log::warn!("Quantity value {value} overflowed when taking absolute value");
i64::MAX
}) as u64;
parse_contracts_quantity(abs_value, instrument)
}
#[must_use]
pub fn parse_fractional_quantity(value: f64, instrument: &InstrumentAny) -> Quantity {
if value < 0.0 {
log::warn!("Received negative fractional quantity {value}, defaulting to 0.0");
return instrument.make_qty(0.0, None);
}
instrument.try_make_qty(value, None).unwrap_or_else(|e| {
log::warn!(
"Failed to convert fractional quantity {value} with precision {}: {e}",
instrument.size_precision(),
);
instrument.make_qty(0.0, None)
})
}
#[must_use]
pub fn normalize_trade_bin_prices(
open: Price,
mut high: Price,
mut low: Price,
close: Price,
symbol: &Ustr,
bar_type: Option<&BarType>,
) -> (Price, Price, Price, Price) {
let price_extremes = [open, high, low, close];
let max_price = *price_extremes
.iter()
.max()
.expect("Price array contains values");
let min_price = *price_extremes
.iter()
.min()
.expect("Price array contains values");
if high < max_price || low > min_price {
match bar_type {
Some(bt) => {
log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}, bar_type={bt:?}");
}
None => log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}"),
}
high = max_price;
low = min_price;
}
(open, high, low, close)
}
#[must_use]
pub fn normalize_trade_bin_volume(volume: Option<i64>, symbol: &Ustr) -> u64 {
match volume {
Some(v) if v >= 0 => v as u64,
Some(v) => {
log::warn!("Received negative volume in BitMEX trade bin: symbol={symbol}, volume={v}");
0
}
None => {
log::warn!("Trade bin missing volume, defaulting to 0: symbol={symbol}");
0
}
}
}
#[must_use]
pub fn parse_optional_datetime_to_unix_nanos(
value: &Option<DateTime<Utc>>,
field: &str,
) -> UnixNanos {
value
.map(|dt| {
UnixNanos::from(dt.timestamp_nanos_opt().unwrap_or_else(|| {
log::error!("Invalid timestamp - out of range: field={field}, timestamp={dt:?}");
0
}) as u64)
})
.unwrap_or_default()
}
#[must_use]
pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
match side {
Some(BitmexSide::Buy) => AggressorSide::Buyer,
Some(BitmexSide::Sell) => AggressorSide::Seller,
None => AggressorSide::NoAggressor,
}
}
#[must_use]
pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
liquidity.map_or(LiquiditySide::NoLiquiditySide, std::convert::Into::into)
}
#[must_use]
pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
match current_qty {
Some(qty) if qty > 0 => PositionSide::Long,
Some(qty) if qty < 0 => PositionSide::Short,
_ => PositionSide::Flat,
}
}
#[must_use]
pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
match bitmex_currency {
"XBt" => Cow::Borrowed("XBT"),
"USDt" | "LAMp" => Cow::Borrowed("USDT"), "RLUSd" => Cow::Borrowed("RLUSD"),
"MAMUSd" => Cow::Borrowed("MAMUSD"),
other => Cow::Owned(other.to_uppercase()),
}
}
#[must_use]
pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
match bitmex_currency {
"XBt" => Decimal::from(100_000_000),
"USDt" | "LAMp" | "MAMUSd" | "RLUSd" => Decimal::from(1_000_000),
_ => Decimal::ONE,
}
}
pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
log::debug!(
"Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
margin.currency,
margin.wallet_balance,
margin.available_margin,
margin.init_margin,
margin.maint_margin,
);
let currency_str = map_bitmex_currency(&margin.currency);
let currency = match Currency::try_from_str(¤cy_str) {
Some(c) => c,
None => {
log::warn!(
"Unknown currency '{currency_str}' in margin message, creating default crypto currency"
);
let currency = Currency::new(¤cy_str, 8, 0, ¤cy_str, CurrencyType::Crypto);
if let Err(e) = Currency::register(currency, false) {
log::error!("Failed to register currency '{currency_str}': {e}");
}
currency
}
};
let divisor = match margin.currency.as_str() {
"XBt" => 100_000_000.0, "USDt" | "LAMp" | "MAMUSd" | "RLUSd" => 1_000_000.0, _ => 1.0,
};
let total = if let Some(wallet_balance) = margin.wallet_balance {
Money::new(wallet_balance as f64 / divisor, currency)
} else if let Some(margin_balance) = margin.margin_balance {
Money::new(margin_balance as f64 / divisor, currency)
} else if let Some(available) = margin.available_margin {
Money::new(available as f64 / divisor, currency)
} else {
Money::new(0.0, currency)
};
let margin_used = if let Some(init_margin) = margin.init_margin {
Money::new(init_margin as f64 / divisor, currency)
} else {
Money::new(0.0, currency)
};
let free = if let Some(withdrawable) = margin.withdrawable_margin {
Money::new(withdrawable as f64 / divisor, currency)
} else if let Some(available) = margin.available_margin {
let available_money = Money::new(available as f64 / divisor, currency);
if available_money > total {
total
} else {
available_money
}
} else {
let calculated_free = total - margin_used;
if calculated_free < Money::new(0.0, currency) {
Money::new(0.0, currency)
} else {
calculated_free
}
};
let locked = total - free;
AccountBalance::new(total, locked, free)
}
pub fn parse_account_state(
margin: &BitmexMarginMsg,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<AccountState> {
let balance = parse_account_balance(margin);
let balances = vec![balance];
let currency_str = map_bitmex_currency(margin.currency.as_str());
let currency = balance.total.currency;
let mut margins = Vec::new();
let divisor = bitmex_currency_divisor(margin.currency.as_str());
let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
let margin_instrument_id = InstrumentId::new(
Symbol::from_str_unchecked(format!("ACCOUNT-{currency_str}")),
*BITMEX_VENUE,
);
margins.push(MarginBalance::new(
Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
Money::from_decimal(maintenance_dec, currency)
.unwrap_or_else(|_| Money::zero(currency)),
margin_instrument_id,
));
}
let account_type = AccountType::Margin;
let is_reported = true;
let event_id = UUID4::new();
let ts_event =
UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
Ok(AccountState::new(
account_id,
account_type,
balances,
margins,
is_reported,
event_id,
ts_event,
ts_init,
None,
))
}
pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
let value = params.and_then(|p| p.get_str("peg_price_type"));
match value {
Some(s) => BitmexPegPriceType::from_str(s)
.map(Some)
.map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
None => Ok(None),
}
}
pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
let value = params.and_then(|p| p.get_str("peg_offset_value"));
match value {
Some(s) => s
.parse::<f64>()
.map(Some)
.map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
use rstest::rstest;
use ustr::Ustr;
use super::*;
#[rstest]
fn test_clean_reason_strips_nautilus_trader() {
assert_eq!(
clean_reason(
"Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
),
"Canceled: Order had execInst of ParticipateDoNotInitiate"
);
assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
assert_eq!(
clean_reason("Multiple lines\nSome content\nNautilusTrader"),
"Multiple lines\nSome content"
);
assert_eq!(clean_reason("No identifier here"), "No identifier here");
assert_eq!(clean_reason(" \nNautilusTrader "), "");
}
fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
let raw_symbol = Symbol::from("SOLUSDT");
let base_currency = Currency::from("SOL");
let quote_currency = Currency::from("USDT");
let price_precision = 2;
let price_increment = Price::new(0.01, price_precision);
let size_increment = Quantity::new(size_increment, size_precision);
let instrument = CurrencyPair::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
price_precision,
size_precision,
price_increment,
size_increment,
None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::from(0),
UnixNanos::from(0),
);
InstrumentAny::CurrencyPair(instrument)
}
#[rstest]
fn test_quantity_to_u32_scaled() {
let instrument = make_test_spot_instrument(0.0001, 4);
let qty = Quantity::new(0.1, 4);
assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
}
#[rstest]
fn test_parse_contracts_quantity_scaled() {
let instrument = make_test_spot_instrument(0.0001, 4);
let qty = parse_contracts_quantity(1_000, &instrument);
assert!((qty.as_f64() - 0.1).abs() < 1e-9);
assert_eq!(qty.precision, 4);
}
#[rstest]
fn test_convert_contract_quantity_scaling() {
let max_scale = FIXED_PRECISION as u32;
let (contract_decimal, size_increment) =
derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
let lot_qty =
convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
.unwrap()
.unwrap();
assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
assert_eq!(lot_qty.precision, 1);
}
#[rstest]
fn test_derive_contract_decimal_defaults_to_one() {
let max_scale = FIXED_PRECISION as u32;
let (contract_decimal, size_increment) =
derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
assert_eq!(contract_decimal, Decimal::ONE);
assert_eq!(size_increment.as_f64(), 1.0);
}
#[rstest]
fn test_parse_account_state() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("XBt"),
risk_limit: Some(1000000000),
amount: Some(5000000),
prev_realised_pnl: Some(100000),
gross_comm: Some(1000),
gross_open_cost: Some(200000),
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: Some(210000),
risk_value: Some(50000),
init_margin: Some(20000),
maint_margin: Some(10000),
target_excess_margin: Some(5000),
realised_pnl: Some(100000),
unrealised_pnl: Some(10000),
wallet_balance: Some(5000000),
margin_balance: Some(5010000),
margin_leverage: Some(2.5),
margin_used_pcnt: Some(0.25),
excess_margin: Some(4990000),
available_margin: Some(4980000),
withdrawable_margin: Some(4900000),
maker_fee_discount: Some(0.1),
taker_fee_discount: Some(0.05),
timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
foreign_margin_balance: None,
foreign_requirement: None,
};
let account_id = AccountId::new("BITMEX-001");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
assert_eq!(account_state.account_id, account_id);
assert_eq!(account_state.account_type, AccountType::Margin);
assert_eq!(account_state.balances.len(), 1);
assert_eq!(account_state.margins.len(), 1);
assert!(account_state.is_reported);
let xbt_balance = &account_state.balances[0];
assert_eq!(xbt_balance.currency, Currency::from("XBT"));
assert_eq!(xbt_balance.total.as_f64(), 0.05); assert_eq!(xbt_balance.free.as_f64(), 0.049); assert_eq!(xbt_balance.locked.as_f64(), 0.001);
let xbt_margin = &account_state.margins[0];
assert_eq!(xbt_margin.initial.as_f64(), 0.0002); assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); }
#[rstest]
fn test_parse_account_state_usdt() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("USDt"),
risk_limit: Some(1000000000),
amount: Some(10000000000), prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: Some(500000), maint_margin: Some(250000), target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: Some(10000000000),
margin_balance: Some(10000000000),
margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(9500000000), withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
foreign_margin_balance: None,
foreign_requirement: None,
};
let account_id = AccountId::new("BITMEX-001");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
let usdt_balance = &account_state.balances[0];
assert_eq!(usdt_balance.currency, Currency::USDT());
assert_eq!(usdt_balance.total.as_f64(), 10000.0);
assert_eq!(usdt_balance.free.as_f64(), 9500.0);
assert_eq!(usdt_balance.locked.as_f64(), 500.0);
assert_eq!(account_state.margins.len(), 1);
let usdt_margin = &account_state.margins[0];
assert_eq!(usdt_margin.initial.as_f64(), 0.5); assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); }
#[rstest]
fn test_parse_margin_message_with_missing_fields() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("XBt"),
risk_limit: None,
amount: None,
prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: None, maint_margin: None, target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: Some(100000),
margin_balance: None,
margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(95000),
withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc::now(),
foreign_margin_balance: None,
foreign_requirement: None,
};
let account_id = AccountId::new("BITMEX-123456");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init)
.expect("Should parse even with missing margin fields");
assert_eq!(account_state.balances.len(), 1);
assert_eq!(account_state.margins.len(), 0); }
#[rstest]
fn test_parse_margin_message_with_only_available_margin() {
let margin_msg = BitmexMarginMsg {
account: 1667725,
currency: Ustr::from("USDt"),
risk_limit: None,
amount: None,
prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: None,
maint_margin: None,
target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: None, margin_balance: None, margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(107859036), withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc::now(),
foreign_margin_balance: None,
foreign_requirement: None,
};
let account_id = AccountId::new("BITMEX-1667725");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init)
.expect("Should handle case with only available_margin");
let balance = &account_state.balances[0];
assert_eq!(balance.currency, Currency::USDT());
assert_eq!(balance.total.as_f64(), 107.859036); assert_eq!(balance.free.as_f64(), 107.859036);
assert_eq!(balance.locked.as_f64(), 0.0);
assert_eq!(balance.total, balance.locked + balance.free);
}
#[rstest]
fn test_parse_margin_available_exceeds_wallet() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("XBt"),
risk_limit: None,
amount: Some(70772),
prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: Some(0),
maint_margin: Some(0),
target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: Some(70772), margin_balance: None,
margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(94381), withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc::now(),
foreign_margin_balance: None,
foreign_requirement: None,
};
let account_id = AccountId::new("BITMEX-123456");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init)
.expect("Should handle available > wallet case");
let balance = &account_state.balances[0];
assert_eq!(balance.currency, Currency::from("XBT"));
assert_eq!(balance.total.as_f64(), 0.00070772); assert_eq!(balance.free.as_f64(), 0.00070772); assert_eq!(balance.locked.as_f64(), 0.0);
assert_eq!(balance.total, balance.locked + balance.free);
}
#[rstest]
fn test_parse_margin_message_with_foreign_requirements() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("XBt"),
risk_limit: Some(1000000000),
amount: Some(100000000), prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: None, maint_margin: None, target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: Some(100000000),
margin_balance: Some(100000000),
margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(95000000), withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc::now(),
foreign_margin_balance: Some(100000000), foreign_requirement: Some(5000000), };
let account_id = AccountId::new("BITMEX-123456");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init)
.expect("Failed to parse account state with foreign requirements");
let balance = &account_state.balances[0];
assert_eq!(balance.currency, Currency::from("XBT"));
assert_eq!(balance.total.as_f64(), 1.0);
assert_eq!(balance.free.as_f64(), 0.95);
assert_eq!(balance.locked.as_f64(), 0.05);
assert_eq!(account_state.margins.len(), 0);
}
#[rstest]
fn test_parse_margin_message_with_both_standard_and_foreign() {
let margin_msg = BitmexMarginMsg {
account: 123456,
currency: Ustr::from("XBt"),
risk_limit: Some(1000000000),
amount: Some(100000000), prev_realised_pnl: None,
gross_comm: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
gross_mark_value: None,
risk_value: None,
init_margin: Some(2000000), maint_margin: Some(1000000), target_excess_margin: None,
realised_pnl: None,
unrealised_pnl: None,
wallet_balance: Some(100000000),
margin_balance: Some(100000000),
margin_leverage: None,
margin_used_pcnt: None,
excess_margin: None,
available_margin: Some(93000000), withdrawable_margin: None,
maker_fee_discount: None,
taker_fee_discount: None,
timestamp: chrono::Utc::now(),
foreign_margin_balance: Some(100000000),
foreign_requirement: Some(5000000), };
let account_id = AccountId::new("BITMEX-123456");
let ts_init = UnixNanos::from(1_000_000_000);
let account_state = parse_account_state(&margin_msg, account_id, ts_init)
.expect("Failed to parse account state with both margins");
let balance = &account_state.balances[0];
assert_eq!(balance.currency, Currency::from("XBT"));
assert_eq!(balance.total.as_f64(), 1.0);
assert_eq!(balance.free.as_f64(), 0.93);
assert_eq!(balance.locked.as_f64(), 0.07);
assert_eq!(account_state.margins.len(), 1);
let xbt_margin = &account_state.margins[0];
assert_eq!(xbt_margin.initial.as_f64(), 0.02); assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); }
}