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