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