Skip to main content

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