nautilus_bitmex/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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//! Shared parsing helpers that transform BitMEX payloads into Nautilus types.
17
18use std::borrow::Cow;
19
20use chrono::{DateTime, Utc};
21use nautilus_core::{nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23    data::bar::BarType,
24    enums::{AccountType, AggressorSide, CurrencyType, LiquiditySide, PositionSide},
25    events::AccountState,
26    identifiers::{AccountId, InstrumentId, Symbol},
27    instruments::{Instrument, InstrumentAny},
28    types::{
29        AccountBalance, Currency, Money, Price, Quantity,
30        quantity::{QUANTITY_RAW_MAX, QuantityRaw},
31    },
32};
33use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
34use ustr::Ustr;
35
36use crate::{
37    common::{
38        consts::BITMEX_VENUE,
39        enums::{BitmexLiquidityIndicator, BitmexSide},
40    },
41    websocket::messages::BitmexMarginMsg,
42};
43
44/// Strip NautilusTrader identifier from BitMEX rejection/cancellation reasons.
45///
46/// BitMEX appends our `text` field as `\nNautilusTrader` to their messages.
47#[must_use]
48pub fn clean_reason(reason: &str) -> String {
49    reason.replace("\nNautilusTrader", "").trim().to_string()
50}
51
52/// Parses a Nautilus instrument ID from the given BitMEX `symbol` value.
53#[must_use]
54pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
55    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *BITMEX_VENUE)
56}
57
58/// Safely converts a `Quantity` into the integer units expected by the BitMEX REST API.
59///
60/// The API expects whole-number "contract" counts which vary per instrument. We always use the
61/// instrument size increment (sourced from BitMEX `underlyingToPositionMultiplier`) to translate
62/// Nautilus quantities back to venue units, so each instrument can have its own contract multiplier.
63/// Values are rounded to the nearest whole contract (midpoint rounds away from zero) and clamped
64/// to `u32::MAX` when necessary.
65#[must_use]
66pub fn quantity_to_u32(quantity: &Quantity, instrument: &InstrumentAny) -> u32 {
67    let size_increment = instrument.size_increment();
68    let step_decimal = size_increment.as_decimal();
69
70    if step_decimal.is_zero() {
71        let value = quantity.as_f64();
72        if value > u32::MAX as f64 {
73            tracing::warn!(
74                "Quantity {value} exceeds u32::MAX without instrument increment, clamping",
75            );
76            return u32::MAX;
77        }
78        return value.max(0.0) as u32;
79    }
80
81    let units_decimal = quantity.as_decimal() / step_decimal;
82    let rounded_units =
83        units_decimal.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
84
85    match rounded_units.to_u128() {
86        Some(units) if units <= u32::MAX as u128 => units as u32,
87        Some(units) => {
88            tracing::warn!(
89                "Quantity {} converts to {units} contracts which exceeds u32::MAX, clamping",
90                quantity.as_f64(),
91            );
92            u32::MAX
93        }
94        None => {
95            tracing::warn!(
96                "Failed to convert quantity {} to venue units, defaulting to 0",
97                quantity.as_f64(),
98            );
99            0
100        }
101    }
102}
103
104/// Converts a BitMEX contracts value into a Nautilus quantity using instrument precision.
105#[must_use]
106pub fn parse_contracts_quantity(value: u64, instrument: &InstrumentAny) -> Quantity {
107    let size_increment = instrument.size_increment();
108    let precision = instrument.size_precision();
109
110    let increment_raw: QuantityRaw = (&size_increment).into();
111    let value_raw = QuantityRaw::from(value);
112
113    let mut raw = increment_raw.saturating_mul(value_raw);
114    if raw > QUANTITY_RAW_MAX {
115        tracing::warn!(
116            "Quantity value {value} exceeds QUANTITY_RAW_MAX {}, clamping",
117            QUANTITY_RAW_MAX,
118        );
119        raw = QUANTITY_RAW_MAX;
120    }
121
122    Quantity::from_raw(raw, precision)
123}
124
125/// Converts the BitMEX `underlyingToPositionMultiplier` into a normalized contract size and
126/// size increment for Nautilus instruments.
127///
128/// The returned decimal retains BitMEX precision (clamped to `max_scale`) so downstream
129/// quantity conversions stay lossless.
130///
131/// # Errors
132///
133/// Returns an error when the multiplier cannot be represented with the configured precision.
134pub fn derive_contract_decimal_and_increment(
135    multiplier: Option<f64>,
136    max_scale: u32,
137) -> anyhow::Result<(Decimal, Quantity)> {
138    let raw_multiplier = multiplier.unwrap_or(1.0);
139    let contract_size = if raw_multiplier > 0.0 {
140        1.0 / raw_multiplier
141    } else {
142        1.0
143    };
144
145    let mut contract_decimal = Decimal::from_f64_retain(contract_size)
146        .ok_or_else(|| anyhow::anyhow!("Invalid contract size {contract_size}"))?;
147    if contract_decimal.scale() > max_scale {
148        contract_decimal = contract_decimal
149            .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
150    }
151    contract_decimal = contract_decimal.normalize();
152    let contract_precision = contract_decimal.scale() as u8;
153    let size_increment = Quantity::from_decimal_dp(contract_decimal, contract_precision)?;
154
155    Ok((contract_decimal, size_increment))
156}
157
158/// Converts an optional contract-count field (e.g. `lotSize`, `maxOrderQty`) into a Nautilus
159/// quantity using the previously derived contract size.
160///
161/// # Errors
162///
163/// Returns an error when the raw value cannot be represented with the available precision.
164pub fn convert_contract_quantity(
165    value: Option<f64>,
166    contract_decimal: Decimal,
167    max_scale: u32,
168    field_name: &str,
169) -> anyhow::Result<Option<Quantity>> {
170    value
171        .map(|raw| {
172            let mut decimal = Decimal::from_f64_retain(raw)
173                .ok_or_else(|| anyhow::anyhow!("Invalid {field_name} value"))?
174                * contract_decimal;
175            let scale = decimal.scale();
176            if scale > max_scale {
177                decimal = decimal
178                    .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
179            }
180            let decimal = decimal.normalize();
181            let precision = decimal.scale() as u8;
182            Quantity::from_decimal_dp(decimal, precision)
183        })
184        .transpose()
185}
186
187/// Converts a signed BitMEX contracts value into a Nautilus quantity using instrument precision.
188#[must_use]
189pub fn parse_signed_contracts_quantity(value: i64, instrument: &InstrumentAny) -> Quantity {
190    let abs_value = value.checked_abs().unwrap_or_else(|| {
191        tracing::warn!("Quantity value {value} overflowed when taking absolute value");
192        i64::MAX
193    }) as u64;
194    parse_contracts_quantity(abs_value, instrument)
195}
196
197/// Converts a fractional size into a quantity honoring the instrument precision.
198#[must_use]
199pub fn parse_fractional_quantity(value: f64, instrument: &InstrumentAny) -> Quantity {
200    if value < 0.0 {
201        tracing::warn!("Received negative fractional quantity {value}, defaulting to 0.0");
202        return instrument.make_qty(0.0, None);
203    }
204
205    instrument.try_make_qty(value, None).unwrap_or_else(|err| {
206        tracing::warn!(
207            "Failed to convert fractional quantity {value} with precision {}: {err}",
208            instrument.size_precision(),
209        );
210        instrument.make_qty(0.0, None)
211    })
212}
213
214/// Normalizes the OHLC values reported by BitMEX trade bins to ensure `high >= max(open, close)`
215/// and `low <= min(open, close)`.
216///
217/// # Panics
218///
219/// Panics if the price array is empty. This should never occur because the caller always supplies
220/// four price values (open/high/low/close).
221#[must_use]
222pub fn normalize_trade_bin_prices(
223    open: Price,
224    mut high: Price,
225    mut low: Price,
226    close: Price,
227    symbol: &Ustr,
228    bar_type: Option<&BarType>,
229) -> (Price, Price, Price, Price) {
230    let price_extremes = [open, high, low, close];
231    let max_price = *price_extremes
232        .iter()
233        .max()
234        .expect("Price array contains values");
235    let min_price = *price_extremes
236        .iter()
237        .min()
238        .expect("Price array contains values");
239
240    if high < max_price || low > min_price {
241        match bar_type {
242            Some(bt) => {
243                tracing::warn!(symbol = %symbol, ?bt, "Adjusting BitMEX trade bin extremes");
244            }
245            None => tracing::warn!(symbol = %symbol, "Adjusting BitMEX trade bin extremes"),
246        }
247        high = max_price;
248        low = min_price;
249    }
250
251    (open, high, low, close)
252}
253
254/// Normalizes the volume reported by BitMEX trade bins, defaulting to zero when the exchange
255/// returns negative or missing values.
256#[must_use]
257pub fn normalize_trade_bin_volume(volume: Option<i64>, symbol: &Ustr) -> u64 {
258    match volume {
259        Some(v) if v >= 0 => v as u64,
260        Some(v) => {
261            tracing::warn!(symbol = %symbol, volume = v, "Received negative volume in BitMEX trade bin");
262            0
263        }
264        None => {
265            tracing::warn!(symbol = %symbol, "Trade bin missing volume, defaulting to 0");
266            0
267        }
268    }
269}
270
271/// Parses the given datetime (UTC) into a `UnixNanos` timestamp.
272/// If `value` is `None`, then defaults to the UNIX epoch (0 nanoseconds).
273///
274/// Returns epoch (0) for invalid timestamps that cannot be converted to nanoseconds.
275#[must_use]
276pub fn parse_optional_datetime_to_unix_nanos(
277    value: &Option<DateTime<Utc>>,
278    field: &str,
279) -> UnixNanos {
280    value
281        .map(|dt| {
282            UnixNanos::from(dt.timestamp_nanos_opt().unwrap_or_else(|| {
283                tracing::error!(field = field, timestamp = ?dt, "Invalid timestamp - out of range");
284                0
285            }) as u64)
286        })
287        .unwrap_or_default()
288}
289
290/// Maps an optional BitMEX side to the corresponding Nautilus aggressor side.
291#[must_use]
292pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
293    match side {
294        Some(BitmexSide::Buy) => AggressorSide::Buyer,
295        Some(BitmexSide::Sell) => AggressorSide::Seller,
296        None => AggressorSide::NoAggressor,
297    }
298}
299
300/// Maps BitMEX liquidity indicators onto Nautilus liquidity sides.
301#[must_use]
302pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
303    liquidity
304        .map(std::convert::Into::into)
305        .unwrap_or(LiquiditySide::NoLiquiditySide)
306}
307
308/// Derives a Nautilus position side from the BitMEX `currentQty` value.
309#[must_use]
310pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
311    match current_qty {
312        Some(qty) if qty > 0 => PositionSide::Long,
313        Some(qty) if qty < 0 => PositionSide::Short,
314        _ => PositionSide::Flat,
315    }
316}
317
318/// Maps BitMEX currency codes to standard Nautilus currency codes.
319///
320/// BitMEX uses some non-standard currency codes:
321/// - "XBt" -> "XBT" (Bitcoin)
322/// - "USDt" -> "USDT" (Tether)
323/// - "LAMp" -> "USDT" (Test currency, mapped to USDT)
324/// - "RLUSd" -> "RLUSD" (Ripple USD stablecoin)
325/// - "MAMUSd" -> "MAMUSD" (Unknown stablecoin)
326///
327/// For other currencies, converts to uppercase.
328#[must_use]
329pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
330    match bitmex_currency {
331        "XBt" => Cow::Borrowed("XBT"),
332        "USDt" | "LAMp" => Cow::Borrowed("USDT"), // LAMp is test currency
333        "RLUSd" => Cow::Borrowed("RLUSD"),
334        "MAMUSd" => Cow::Borrowed("MAMUSD"),
335        other => Cow::Owned(other.to_uppercase()),
336    }
337}
338
339/// Parses a BitMEX margin message into a Nautilus account state.
340///
341/// # Errors
342///
343/// Returns an error if the margin data cannot be parsed into valid balance values.
344pub fn parse_account_state(
345    margin: &BitmexMarginMsg,
346    account_id: AccountId,
347    ts_init: UnixNanos,
348) -> anyhow::Result<AccountState> {
349    tracing::debug!(
350        "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}, foreign_margin_balance={:?}, foreign_requirement={:?}",
351        margin.currency,
352        margin.wallet_balance,
353        margin.available_margin,
354        margin.init_margin,
355        margin.maint_margin,
356        margin.foreign_margin_balance,
357        margin.foreign_requirement
358    );
359
360    let currency_str = map_bitmex_currency(&margin.currency);
361
362    let currency = match Currency::try_from_str(&currency_str) {
363        Some(c) => c,
364        None => {
365            // Create a default crypto currency for unknown codes to avoid disrupting flows
366            tracing::warn!(
367                "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
368            );
369            let currency = Currency::new(&currency_str, 8, 0, &currency_str, CurrencyType::Crypto);
370            if let Err(e) = Currency::register(currency, false) {
371                tracing::error!("Failed to register currency '{currency_str}': {e}");
372            }
373            currency
374        }
375    };
376
377    // BitMEX returns values in satoshis for BTC (XBt) or microunits for USDT/LAMp
378    let divisor = if margin.currency == "XBt" {
379        100_000_000.0 // Satoshis to BTC
380    } else if margin.currency == "USDt" || margin.currency == "LAMp" {
381        1_000_000.0 // Microunits to units
382    } else {
383        1.0
384    };
385
386    // Wallet balance is the actual asset amount
387    let total = if let Some(wallet_balance) = margin.wallet_balance {
388        Money::new(wallet_balance as f64 / divisor, currency)
389    } else if let Some(margin_balance) = margin.margin_balance {
390        Money::new(margin_balance as f64 / divisor, currency)
391    } else if let Some(available) = margin.available_margin {
392        // Fallback when only available_margin is provided
393        Money::new(available as f64 / divisor, currency)
394    } else {
395        Money::new(0.0, currency)
396    };
397
398    // Calculate how much is locked for margin requirements
399    let margin_used = if let Some(init_margin) = margin.init_margin {
400        Money::new(init_margin as f64 / divisor, currency)
401    } else {
402        Money::new(0.0, currency)
403    };
404
405    // Free balance: prefer withdrawable_margin, then available_margin, then calculate
406    let free = if let Some(withdrawable) = margin.withdrawable_margin {
407        Money::new(withdrawable as f64 / divisor, currency)
408    } else if let Some(available) = margin.available_margin {
409        // Available margin already accounts for orders and positions
410        let available_money = Money::new(available as f64 / divisor, currency);
411        // Ensure it doesn't exceed total (can happen with unrealized PnL)
412        if available_money > total {
413            total
414        } else {
415            available_money
416        }
417    } else {
418        // Fallback: free = total - init_margin
419        let calculated_free = total - margin_used;
420        if calculated_free < Money::new(0.0, currency) {
421            Money::new(0.0, currency)
422        } else {
423            calculated_free
424        }
425    };
426
427    // Locked is what's being used for margin
428    let locked = total - free;
429
430    let balance = AccountBalance::new(total, locked, free);
431    let balances = vec![balance];
432
433    // Skip margin details - BitMEX uses account-level cross-margin which doesn't map
434    // well to Nautilus's per-instrument margin model, we track balances only.
435    let margins = Vec::new();
436
437    let account_type = AccountType::Margin;
438    let is_reported = true;
439    let event_id = UUID4::new();
440    let ts_event =
441        UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
442
443    Ok(AccountState::new(
444        account_id,
445        account_type,
446        balances,
447        margins,
448        is_reported,
449        event_id,
450        ts_event,
451        ts_init,
452        None,
453    ))
454}
455
456#[cfg(test)]
457mod tests {
458    use chrono::TimeZone;
459    use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
460    use rstest::rstest;
461    use ustr::Ustr;
462
463    use super::*;
464
465    #[rstest]
466    fn test_clean_reason_strips_nautilus_trader() {
467        assert_eq!(
468            clean_reason(
469                "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
470            ),
471            "Canceled: Order had execInst of ParticipateDoNotInitiate"
472        );
473
474        assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
475        assert_eq!(
476            clean_reason("Multiple lines\nSome content\nNautilusTrader"),
477            "Multiple lines\nSome content"
478        );
479        assert_eq!(clean_reason("No identifier here"), "No identifier here");
480        assert_eq!(clean_reason("  \nNautilusTrader  "), "");
481    }
482
483    fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
484        let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
485        let raw_symbol = Symbol::from("SOLUSDT");
486        let base_currency = Currency::from("SOL");
487        let quote_currency = Currency::from("USDT");
488        let price_precision = 2;
489        let price_increment = Price::new(0.01, price_precision);
490        let size_increment = Quantity::new(size_increment, size_precision);
491        let instrument = CurrencyPair::new(
492            instrument_id,
493            raw_symbol,
494            base_currency,
495            quote_currency,
496            price_precision,
497            size_precision,
498            price_increment,
499            size_increment,
500            None, // multiplier
501            None, // lot_size
502            None, // max_quantity
503            None, // min_quantity
504            None, // max_notional
505            None, // min_notional
506            None, // max_price
507            None, // min_price
508            None, // margin_init
509            None, // margin_maint
510            None, // maker_fee
511            None, // taker_fee
512            UnixNanos::from(0),
513            UnixNanos::from(0),
514        );
515        InstrumentAny::CurrencyPair(instrument)
516    }
517
518    #[rstest]
519    fn test_quantity_to_u32_scaled() {
520        let instrument = make_test_spot_instrument(0.0001, 4);
521        let qty = Quantity::new(0.1, 4);
522        assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
523    }
524
525    #[rstest]
526    fn test_parse_contracts_quantity_scaled() {
527        let instrument = make_test_spot_instrument(0.0001, 4);
528        let qty = parse_contracts_quantity(1_000, &instrument);
529        assert!((qty.as_f64() - 0.1).abs() < 1e-9);
530        assert_eq!(qty.precision, 4);
531    }
532
533    #[rstest]
534    fn test_convert_contract_quantity_scaling() {
535        let max_scale = FIXED_PRECISION as u32;
536        let (contract_decimal, size_increment) =
537            derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
538        assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
539
540        let lot_qty =
541            convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
542                .unwrap()
543                .unwrap();
544        assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
545        assert_eq!(lot_qty.precision, 1);
546    }
547
548    #[rstest]
549    fn test_derive_contract_decimal_defaults_to_one() {
550        let max_scale = FIXED_PRECISION as u32;
551        let (contract_decimal, size_increment) =
552            derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
553        assert_eq!(contract_decimal, Decimal::ONE);
554        assert_eq!(size_increment.as_f64(), 1.0);
555    }
556
557    #[rstest]
558    fn test_parse_account_state() {
559        let margin_msg = BitmexMarginMsg {
560            account: 123456,
561            currency: Ustr::from("XBt"),
562            risk_limit: Some(1000000000),
563            amount: Some(5000000),
564            prev_realised_pnl: Some(100000),
565            gross_comm: Some(1000),
566            gross_open_cost: Some(200000),
567            gross_open_premium: None,
568            gross_exec_cost: None,
569            gross_mark_value: Some(210000),
570            risk_value: Some(50000),
571            init_margin: Some(20000),
572            maint_margin: Some(10000),
573            target_excess_margin: Some(5000),
574            realised_pnl: Some(100000),
575            unrealised_pnl: Some(10000),
576            wallet_balance: Some(5000000),
577            margin_balance: Some(5010000),
578            margin_leverage: Some(2.5),
579            margin_used_pcnt: Some(0.25),
580            excess_margin: Some(4990000),
581            available_margin: Some(4980000),
582            withdrawable_margin: Some(4900000),
583            maker_fee_discount: Some(0.1),
584            taker_fee_discount: Some(0.05),
585            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
586            foreign_margin_balance: None,
587            foreign_requirement: None,
588        };
589
590        let account_id = AccountId::new("BITMEX-001");
591        let ts_init = UnixNanos::from(1_000_000_000);
592
593        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
594
595        assert_eq!(account_state.account_id, account_id);
596        assert_eq!(account_state.account_type, AccountType::Margin);
597        assert_eq!(account_state.balances.len(), 1);
598        assert_eq!(account_state.margins.len(), 0); // No margins tracked
599        assert!(account_state.is_reported);
600
601        let xbt_balance = &account_state.balances[0];
602        assert_eq!(xbt_balance.currency, Currency::from("XBT"));
603        assert_eq!(xbt_balance.total.as_f64(), 0.05); // 5000000 satoshis = 0.05 XBT wallet balance
604        assert_eq!(xbt_balance.free.as_f64(), 0.049); // 4900000 satoshis = 0.049 XBT withdrawable
605        assert_eq!(xbt_balance.locked.as_f64(), 0.001); // 100000 satoshis locked
606    }
607
608    #[rstest]
609    fn test_parse_account_state_usdt() {
610        let margin_msg = BitmexMarginMsg {
611            account: 123456,
612            currency: Ustr::from("USDt"),
613            risk_limit: Some(1000000000),
614            amount: Some(10000000000), // 10000 USDT in microunits
615            prev_realised_pnl: None,
616            gross_comm: None,
617            gross_open_cost: None,
618            gross_open_premium: None,
619            gross_exec_cost: None,
620            gross_mark_value: None,
621            risk_value: None,
622            init_margin: Some(500000),  // 0.5 USDT in microunits
623            maint_margin: Some(250000), // 0.25 USDT in microunits
624            target_excess_margin: None,
625            realised_pnl: None,
626            unrealised_pnl: None,
627            wallet_balance: Some(10000000000),
628            margin_balance: Some(10000000000),
629            margin_leverage: None,
630            margin_used_pcnt: None,
631            excess_margin: None,
632            available_margin: Some(9500000000), // 9500 USDT available
633            withdrawable_margin: None,
634            maker_fee_discount: None,
635            taker_fee_discount: None,
636            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
637            foreign_margin_balance: None,
638            foreign_requirement: None,
639        };
640
641        let account_id = AccountId::new("BITMEX-001");
642        let ts_init = UnixNanos::from(1_000_000_000);
643
644        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
645
646        let usdt_balance = &account_state.balances[0];
647        assert_eq!(usdt_balance.currency, Currency::USDT());
648        assert_eq!(usdt_balance.total.as_f64(), 10000.0);
649        assert_eq!(usdt_balance.free.as_f64(), 9500.0);
650        assert_eq!(usdt_balance.locked.as_f64(), 500.0);
651
652        assert_eq!(account_state.margins.len(), 0); // No margins tracked
653    }
654
655    #[rstest]
656    fn test_parse_margin_message_with_missing_fields() {
657        // Create a margin message with missing optional fields
658        let margin_msg = BitmexMarginMsg {
659            account: 123456,
660            currency: Ustr::from("XBt"),
661            risk_limit: None,
662            amount: None,
663            prev_realised_pnl: None,
664            gross_comm: None,
665            gross_open_cost: None,
666            gross_open_premium: None,
667            gross_exec_cost: None,
668            gross_mark_value: None,
669            risk_value: None,
670            init_margin: None,  // Missing
671            maint_margin: None, // Missing
672            target_excess_margin: None,
673            realised_pnl: None,
674            unrealised_pnl: None,
675            wallet_balance: Some(100000),
676            margin_balance: None,
677            margin_leverage: None,
678            margin_used_pcnt: None,
679            excess_margin: None,
680            available_margin: Some(95000),
681            withdrawable_margin: None,
682            maker_fee_discount: None,
683            taker_fee_discount: None,
684            timestamp: chrono::Utc::now(),
685            foreign_margin_balance: None,
686            foreign_requirement: None,
687        };
688
689        let account_id = AccountId::new("BITMEX-123456");
690        let ts_init = UnixNanos::from(1_000_000_000);
691
692        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
693            .expect("Should parse even with missing margin fields");
694
695        // Should have balance but no margins
696        assert_eq!(account_state.balances.len(), 1);
697        assert_eq!(account_state.margins.len(), 0); // No margins tracked
698    }
699
700    #[rstest]
701    fn test_parse_margin_message_with_only_available_margin() {
702        // This is the case we saw in the logs - only available_margin is populated
703        let margin_msg = BitmexMarginMsg {
704            account: 1667725,
705            currency: Ustr::from("USDt"),
706            risk_limit: None,
707            amount: None,
708            prev_realised_pnl: None,
709            gross_comm: None,
710            gross_open_cost: None,
711            gross_open_premium: None,
712            gross_exec_cost: None,
713            gross_mark_value: None,
714            risk_value: None,
715            init_margin: None,
716            maint_margin: None,
717            target_excess_margin: None,
718            realised_pnl: None,
719            unrealised_pnl: None,
720            wallet_balance: None, // None
721            margin_balance: None, // None
722            margin_leverage: None,
723            margin_used_pcnt: None,
724            excess_margin: None,
725            available_margin: Some(107859036), // Only this is populated
726            withdrawable_margin: None,
727            maker_fee_discount: None,
728            taker_fee_discount: None,
729            timestamp: chrono::Utc::now(),
730            foreign_margin_balance: None,
731            foreign_requirement: None,
732        };
733
734        let account_id = AccountId::new("BITMEX-1667725");
735        let ts_init = UnixNanos::from(1_000_000_000);
736
737        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
738            .expect("Should handle case with only available_margin");
739
740        // Check the balance accounting equation holds
741        let balance = &account_state.balances[0];
742        assert_eq!(balance.currency, Currency::USDT());
743        assert_eq!(balance.total.as_f64(), 107.859036); // Total should equal free when only available_margin is present
744        assert_eq!(balance.free.as_f64(), 107.859036);
745        assert_eq!(balance.locked.as_f64(), 0.0);
746
747        // Verify the accounting equation: total = locked + free
748        assert_eq!(balance.total, balance.locked + balance.free);
749    }
750
751    #[rstest]
752    fn test_parse_margin_available_exceeds_wallet() {
753        // Test case where available margin exceeds wallet balance (bonus margin scenario)
754        let margin_msg = BitmexMarginMsg {
755            account: 123456,
756            currency: Ustr::from("XBt"),
757            risk_limit: None,
758            amount: Some(70772),
759            prev_realised_pnl: None,
760            gross_comm: None,
761            gross_open_cost: None,
762            gross_open_premium: None,
763            gross_exec_cost: None,
764            gross_mark_value: None,
765            risk_value: None,
766            init_margin: Some(0),
767            maint_margin: Some(0),
768            target_excess_margin: None,
769            realised_pnl: None,
770            unrealised_pnl: None,
771            wallet_balance: Some(70772), // 0.00070772 BTC
772            margin_balance: None,
773            margin_leverage: None,
774            margin_used_pcnt: None,
775            excess_margin: None,
776            available_margin: Some(94381), // 0.00094381 BTC - exceeds wallet!
777            withdrawable_margin: None,
778            maker_fee_discount: None,
779            taker_fee_discount: None,
780            timestamp: chrono::Utc::now(),
781            foreign_margin_balance: None,
782            foreign_requirement: None,
783        };
784
785        let account_id = AccountId::new("BITMEX-123456");
786        let ts_init = UnixNanos::from(1_000_000_000);
787
788        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
789            .expect("Should handle available > wallet case");
790
791        // Wallet balance is the actual asset amount, not available margin
792        let balance = &account_state.balances[0];
793        assert_eq!(balance.currency, Currency::from("XBT"));
794        assert_eq!(balance.total.as_f64(), 0.00070772); // Wallet balance (actual assets)
795        assert_eq!(balance.free.as_f64(), 0.00070772); // All free since no margin locked
796        assert_eq!(balance.locked.as_f64(), 0.0);
797
798        // Verify the accounting equation: total = locked + free
799        assert_eq!(balance.total, balance.locked + balance.free);
800    }
801
802    #[rstest]
803    fn test_parse_margin_message_with_foreign_requirements() {
804        // Test case where trading USDT-settled contracts with XBT margin
805        let margin_msg = BitmexMarginMsg {
806            account: 123456,
807            currency: Ustr::from("XBt"),
808            risk_limit: Some(1000000000),
809            amount: Some(100000000), // 1 BTC
810            prev_realised_pnl: None,
811            gross_comm: None,
812            gross_open_cost: None,
813            gross_open_premium: None,
814            gross_exec_cost: None,
815            gross_mark_value: None,
816            risk_value: None,
817            init_margin: None,  // No direct margin in XBT
818            maint_margin: None, // No direct margin in XBT
819            target_excess_margin: None,
820            realised_pnl: None,
821            unrealised_pnl: None,
822            wallet_balance: Some(100000000),
823            margin_balance: Some(100000000),
824            margin_leverage: None,
825            margin_used_pcnt: None,
826            excess_margin: None,
827            available_margin: Some(95000000), // 0.95 BTC available
828            withdrawable_margin: None,
829            maker_fee_discount: None,
830            taker_fee_discount: None,
831            timestamp: chrono::Utc::now(),
832            foreign_margin_balance: Some(100000000), // Foreign margin balance in satoshis
833            foreign_requirement: Some(5000000),      // 0.05 BTC required for USDT positions
834        };
835
836        let account_id = AccountId::new("BITMEX-123456");
837        let ts_init = UnixNanos::from(1_000_000_000);
838
839        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
840            .expect("Failed to parse account state with foreign requirements");
841
842        // Check balance
843        let balance = &account_state.balances[0];
844        assert_eq!(balance.currency, Currency::from("XBT"));
845        assert_eq!(balance.total.as_f64(), 1.0);
846        assert_eq!(balance.free.as_f64(), 0.95);
847        assert_eq!(balance.locked.as_f64(), 0.05);
848
849        // No margins tracked
850        assert_eq!(account_state.margins.len(), 0);
851    }
852
853    #[rstest]
854    fn test_parse_margin_message_with_both_standard_and_foreign() {
855        // Test case with both standard and foreign margin requirements
856        let margin_msg = BitmexMarginMsg {
857            account: 123456,
858            currency: Ustr::from("XBt"),
859            risk_limit: Some(1000000000),
860            amount: Some(100000000), // 1 BTC
861            prev_realised_pnl: None,
862            gross_comm: None,
863            gross_open_cost: None,
864            gross_open_premium: None,
865            gross_exec_cost: None,
866            gross_mark_value: None,
867            risk_value: None,
868            init_margin: Some(2000000),  // 0.02 BTC for XBT positions
869            maint_margin: Some(1000000), // 0.01 BTC for XBT positions
870            target_excess_margin: None,
871            realised_pnl: None,
872            unrealised_pnl: None,
873            wallet_balance: Some(100000000),
874            margin_balance: Some(100000000),
875            margin_leverage: None,
876            margin_used_pcnt: None,
877            excess_margin: None,
878            available_margin: Some(93000000), // 0.93 BTC available
879            withdrawable_margin: None,
880            maker_fee_discount: None,
881            taker_fee_discount: None,
882            timestamp: chrono::Utc::now(),
883            foreign_margin_balance: Some(100000000),
884            foreign_requirement: Some(5000000), // 0.05 BTC for USDT positions
885        };
886
887        let account_id = AccountId::new("BITMEX-123456");
888        let ts_init = UnixNanos::from(1_000_000_000);
889
890        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
891            .expect("Failed to parse account state with both margins");
892
893        // Check balance
894        let balance = &account_state.balances[0];
895        assert_eq!(balance.currency, Currency::from("XBT"));
896        assert_eq!(balance.total.as_f64(), 1.0);
897        assert_eq!(balance.free.as_f64(), 0.93);
898        assert_eq!(balance.locked.as_f64(), 0.07); // 0.02 + 0.05 = 0.07 total margin
899
900        // No margins tracked
901        assert_eq!(account_state.margins.len(), 0);
902    }
903}