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::{Params, 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, TradeId},
27    instruments::{Instrument, InstrumentAny},
28    types::{
29        AccountBalance, Currency, MarginBalance, 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, BitmexPegPriceType, BitmexSide},
40    },
41    websocket::messages::BitmexMarginMsg,
42};
43
44// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
45const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
46const FNV_PRIME: u64 = 0x0100_0000_01b3;
47
48/// Strip NautilusTrader identifier from BitMEX rejection/cancellation reasons.
49///
50/// BitMEX appends our `text` field as `\nNautilusTrader` to their messages.
51#[must_use]
52pub fn clean_reason(reason: &str) -> String {
53    reason.replace("\nNautilusTrader", "").trim().to_string()
54}
55
56/// Extracts the trigger type from BitMEX exec instructions.
57#[must_use]
58pub fn extract_trigger_type(exec_inst: Option<&Vec<BitmexExecInstruction>>) -> TriggerType {
59    if let Some(exec_insts) = exec_inst {
60        if exec_insts.contains(&BitmexExecInstruction::MarkPrice) {
61            TriggerType::MarkPrice
62        } else if exec_insts.contains(&BitmexExecInstruction::IndexPrice) {
63            TriggerType::IndexPrice
64        } else if exec_insts.contains(&BitmexExecInstruction::LastPrice) {
65            TriggerType::LastPrice
66        } else {
67            TriggerType::Default
68        }
69    } else {
70        TriggerType::Default
71    }
72}
73
74/// Parses a Nautilus instrument ID from the given BitMEX `symbol` value.
75#[must_use]
76pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
77    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *BITMEX_VENUE)
78}
79
80/// Safely converts a `Quantity` into the integer units expected by the BitMEX REST API.
81///
82/// The API expects whole-number "contract" counts which vary per instrument. We always use the
83/// instrument size increment (sourced from BitMEX `underlyingToPositionMultiplier`) to translate
84/// Nautilus quantities back to venue units, so each instrument can have its own contract multiplier.
85/// Values are rounded to the nearest whole contract (midpoint rounds away from zero) and clamped
86/// to `u32::MAX` when necessary.
87#[must_use]
88pub fn quantity_to_u32(quantity: &Quantity, instrument: &InstrumentAny) -> u32 {
89    let size_increment = instrument.size_increment();
90    let step_decimal = size_increment.as_decimal();
91
92    if step_decimal.is_zero() {
93        let value = quantity.as_f64();
94        if value > u32::MAX as f64 {
95            log::warn!("Quantity {value} exceeds u32::MAX without instrument increment, clamping",);
96            return u32::MAX;
97        }
98        return value.max(0.0) as u32;
99    }
100
101    let units_decimal = quantity.as_decimal() / step_decimal;
102    let rounded_units =
103        units_decimal.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
104
105    match rounded_units.to_u128() {
106        Some(units) if units <= u32::MAX as u128 => units as u32,
107        Some(units) => {
108            log::warn!(
109                "Quantity {} converts to {units} contracts which exceeds u32::MAX, clamping",
110                quantity.as_f64(),
111            );
112            u32::MAX
113        }
114        None => {
115            log::warn!(
116                "Failed to convert quantity {} to venue units, defaulting to 0",
117                quantity.as_f64(),
118            );
119            0
120        }
121    }
122}
123
124/// Converts a BitMEX contracts value into a Nautilus quantity using instrument precision.
125#[must_use]
126pub fn parse_contracts_quantity(value: u64, instrument: &InstrumentAny) -> Quantity {
127    let size_increment = instrument.size_increment();
128    let precision = instrument.size_precision();
129
130    let increment_raw: QuantityRaw = (&size_increment).into();
131    let value_raw = QuantityRaw::from(value);
132
133    let mut raw = increment_raw.saturating_mul(value_raw);
134    if raw > QUANTITY_RAW_MAX {
135        log::warn!("Quantity value {value} exceeds QUANTITY_RAW_MAX {QUANTITY_RAW_MAX}, clamping",);
136        raw = QUANTITY_RAW_MAX;
137    }
138
139    Quantity::from_raw(raw, precision)
140}
141
142/// Converts the BitMEX `underlyingToPositionMultiplier` into a normalized contract size and
143/// size increment for Nautilus instruments.
144///
145/// The returned decimal retains BitMEX precision (clamped to `max_scale`) so downstream
146/// quantity conversions stay lossless.
147///
148/// # Errors
149///
150/// Returns an error when the multiplier cannot be represented with the configured precision.
151pub fn derive_contract_decimal_and_increment(
152    multiplier: Option<f64>,
153    max_scale: u32,
154) -> anyhow::Result<(Decimal, Quantity)> {
155    let raw_multiplier = multiplier.unwrap_or(1.0);
156    let contract_size = if raw_multiplier > 0.0 {
157        1.0 / raw_multiplier
158    } else {
159        1.0
160    };
161
162    let mut contract_decimal = Decimal::from_str(&contract_size.to_string())
163        .map_err(|_| anyhow::anyhow!("Invalid contract size {contract_size}"))?;
164
165    if contract_decimal.scale() > max_scale {
166        contract_decimal = contract_decimal
167            .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
168    }
169    contract_decimal = contract_decimal.normalize();
170    let contract_precision = contract_decimal.scale() as u8;
171    let size_increment = Quantity::from_decimal_dp(contract_decimal, contract_precision)?;
172
173    Ok((contract_decimal, size_increment))
174}
175
176/// Converts an optional contract-count field (e.g. `lotSize`, `maxOrderQty`) into a Nautilus
177/// quantity using the previously derived contract size.
178///
179/// # Errors
180///
181/// Returns an error when the raw value cannot be represented with the available precision.
182pub fn convert_contract_quantity(
183    value: Option<f64>,
184    contract_decimal: Decimal,
185    max_scale: u32,
186    field_name: &str,
187) -> anyhow::Result<Option<Quantity>> {
188    value
189        .map(|raw| {
190            let mut decimal = Decimal::from_str(&raw.to_string())
191                .map_err(|_| anyhow::anyhow!("Invalid {field_name} value"))?
192                * contract_decimal;
193            let scale = decimal.scale();
194            if scale > max_scale {
195                decimal = decimal
196                    .round_dp_with_strategy(max_scale, RoundingStrategy::MidpointAwayFromZero);
197            }
198            let decimal = decimal.normalize();
199            let precision = decimal.scale() as u8;
200            Quantity::from_decimal_dp(decimal, precision).map_err(anyhow::Error::from)
201        })
202        .transpose()
203}
204
205/// Converts a signed BitMEX contracts value into a Nautilus quantity using instrument precision.
206#[must_use]
207pub fn parse_signed_contracts_quantity(value: i64, instrument: &InstrumentAny) -> Quantity {
208    let abs_value = value.checked_abs().unwrap_or_else(|| {
209        log::warn!("Quantity value {value} overflowed when taking absolute value");
210        i64::MAX
211    }) as u64;
212    parse_contracts_quantity(abs_value, instrument)
213}
214
215/// Converts a fractional size into a quantity honoring the instrument precision.
216#[must_use]
217pub fn parse_fractional_quantity(value: f64, instrument: &InstrumentAny) -> Quantity {
218    if value < 0.0 {
219        log::warn!("Received negative fractional quantity {value}, defaulting to 0.0");
220        return instrument.make_qty(0.0, None);
221    }
222
223    instrument.try_make_qty(value, None).unwrap_or_else(|e| {
224        log::warn!(
225            "Failed to convert fractional quantity {value} with precision {}: {e}",
226            instrument.size_precision(),
227        );
228        instrument.make_qty(0.0, None)
229    })
230}
231
232/// Normalizes the OHLC values reported by BitMEX trade bins to ensure `high >= max(open, close)`
233/// and `low <= min(open, close)`.
234///
235/// # Panics
236///
237/// Panics if the price array is empty. This should never occur because the caller always supplies
238/// four price values (open/high/low/close).
239#[must_use]
240pub fn normalize_trade_bin_prices(
241    open: Price,
242    mut high: Price,
243    mut low: Price,
244    close: Price,
245    symbol: &Ustr,
246    bar_type: Option<&BarType>,
247) -> (Price, Price, Price, Price) {
248    let price_extremes = [open, high, low, close];
249    let max_price = *price_extremes
250        .iter()
251        .max()
252        .expect("Price array contains values");
253    let min_price = *price_extremes
254        .iter()
255        .min()
256        .expect("Price array contains values");
257
258    if high < max_price || low > min_price {
259        match bar_type {
260            Some(bt) => {
261                log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}, bar_type={bt:?}");
262            }
263            None => log::warn!("Adjusting BitMEX trade bin extremes: symbol={symbol}"),
264        }
265        high = max_price;
266        low = min_price;
267    }
268
269    (open, high, low, close)
270}
271
272/// Normalizes the volume reported by BitMEX trade bins, defaulting to zero when the exchange
273/// returns negative or missing values.
274#[must_use]
275pub fn normalize_trade_bin_volume(volume: Option<i64>, symbol: &Ustr) -> u64 {
276    match volume {
277        Some(v) if v >= 0 => v as u64,
278        Some(v) => {
279            log::warn!("Received negative volume in BitMEX trade bin: symbol={symbol}, volume={v}");
280            0
281        }
282        None => {
283            log::warn!("Trade bin missing volume, defaulting to 0: symbol={symbol}");
284            0
285        }
286    }
287}
288
289/// Parses the given datetime (UTC) into a `UnixNanos` timestamp.
290/// If `value` is `None`, then defaults to the UNIX epoch (0 nanoseconds).
291///
292/// Returns epoch (0) for invalid timestamps that cannot be converted to nanoseconds.
293#[must_use]
294pub fn parse_optional_datetime_to_unix_nanos(
295    value: &Option<DateTime<Utc>>,
296    field: &str,
297) -> UnixNanos {
298    value
299        .map(|dt| {
300            UnixNanos::from(dt.timestamp_nanos_opt().unwrap_or_else(|| {
301                log::error!("Invalid timestamp - out of range: field={field}, timestamp={dt:?}");
302                0
303            }) as u64)
304        })
305        .unwrap_or_default()
306}
307
308/// Maps an optional BitMEX side to the corresponding Nautilus aggressor side.
309#[must_use]
310pub const fn parse_aggressor_side(side: &Option<BitmexSide>) -> AggressorSide {
311    match side {
312        Some(BitmexSide::Buy) => AggressorSide::Buyer,
313        Some(BitmexSide::Sell) => AggressorSide::Seller,
314        None => AggressorSide::NoAggressor,
315    }
316}
317
318/// Maps BitMEX liquidity indicators onto Nautilus liquidity sides.
319#[must_use]
320pub fn parse_liquidity_side(liquidity: &Option<BitmexLiquidityIndicator>) -> LiquiditySide {
321    liquidity.map_or(LiquiditySide::NoLiquiditySide, std::convert::Into::into)
322}
323
324/// Derives a Nautilus position side from the BitMEX `currentQty` value.
325#[must_use]
326pub const fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
327    match current_qty {
328        Some(qty) if qty > 0 => PositionSide::Long,
329        Some(qty) if qty < 0 => PositionSide::Short,
330        _ => PositionSide::Flat,
331    }
332}
333
334/// Maps BitMEX currency codes to standard Nautilus currency codes.
335///
336/// BitMEX uses some non-standard currency codes:
337/// - "XBt" -> "XBT" (Bitcoin)
338/// - "USDt" -> "USDT" (Tether)
339/// - "LAMp" -> "USDT" (Test currency, mapped to USDT)
340/// - "RLUSd" -> "RLUSD" (Ripple USD stablecoin)
341/// - "MAMUSd" -> "MAMUSD" (Unknown stablecoin)
342///
343/// For other currencies, converts to uppercase.
344#[must_use]
345pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
346    match bitmex_currency {
347        "XBt" => Cow::Borrowed("XBT"),
348        "USDt" | "LAMp" => Cow::Borrowed("USDT"), // LAMp is test currency
349        "RLUSd" => Cow::Borrowed("RLUSD"),
350        "MAMUSd" => Cow::Borrowed("MAMUSD"),
351        other => Cow::Owned(other.to_uppercase()),
352    }
353}
354
355/// Returns the Decimal divisor for converting BitMEX raw integer units to standard units.
356#[must_use]
357pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
358    match bitmex_currency {
359        "XBt" => Decimal::from(100_000_000),
360        "USDt" | "LAMp" | "MAMUSd" | "RLUSd" => Decimal::from(1_000_000),
361        _ => Decimal::ONE,
362    }
363}
364
365/// Parses a BitMEX margin message into a Nautilus account balance.
366pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
367    log::debug!(
368        "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
369        margin.currency,
370        margin.wallet_balance,
371        margin.available_margin,
372        margin.init_margin,
373        margin.maint_margin,
374    );
375
376    let currency_str = map_bitmex_currency(&margin.currency);
377
378    let currency = match Currency::try_from_str(&currency_str) {
379        Some(c) => c,
380        None => {
381            // Create a default crypto currency for unknown codes to avoid disrupting flows
382            log::warn!(
383                "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
384            );
385            let currency = Currency::new(&currency_str, 8, 0, &currency_str, CurrencyType::Crypto);
386            if let Err(e) = Currency::register(currency, false) {
387                log::error!("Failed to register currency '{currency_str}': {e}");
388            }
389            currency
390        }
391    };
392
393    // BitMEX returns values in satoshis for BTC (XBt) or microunits for stablecoins.
394    let divisor = bitmex_currency_divisor(margin.currency.as_str());
395    let to_dec = |raw: i64| Decimal::from(raw) / divisor;
396
397    // Wallet balance is the actual asset amount. Fall back progressively.
398    let total_dec = margin
399        .wallet_balance
400        .map(to_dec)
401        .or_else(|| margin.margin_balance.map(to_dec))
402        .or_else(|| margin.available_margin.map(to_dec))
403        .unwrap_or(Decimal::ZERO);
404
405    // Free balance: prefer withdrawable_margin, then available_margin, then
406    // derive as `total - init_margin`. `from_total_and_free` clamps `free`
407    // into `[0, total]` for non-negative totals, so no manual clamping here.
408    let free_dec = if let Some(withdrawable) = margin.withdrawable_margin {
409        to_dec(withdrawable)
410    } else if let Some(available) = margin.available_margin {
411        to_dec(available)
412    } else {
413        let margin_used = margin.init_margin.map_or(Decimal::ZERO, to_dec);
414        total_dec - margin_used
415    };
416
417    AccountBalance::from_total_and_free(total_dec, free_dec, currency).unwrap_or_else(|e| {
418        log::error!("Failed to build BitMEX account balance: {e}");
419        let zero = Money::zero(currency);
420        AccountBalance::new(zero, zero, zero)
421    })
422}
423
424/// Parses a BitMEX margin message into a Nautilus account state.
425///
426/// # Errors
427///
428/// Returns an error if the margin data cannot be parsed into valid balance values.
429pub fn parse_account_state(
430    margin: &BitmexMarginMsg,
431    account_id: AccountId,
432    ts_init: UnixNanos,
433) -> anyhow::Result<AccountState> {
434    let balance = parse_account_balance(margin);
435    let balances = vec![balance];
436
437    let currency = balance.total.currency;
438    let mut margins = Vec::new();
439
440    let divisor = bitmex_currency_divisor(margin.currency.as_str());
441    let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
442    let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
443
444    if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
445        // BitMEX reports cross-margin aggregates per collateral currency.
446        margins.push(MarginBalance::new(
447            Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
448            Money::from_decimal(maintenance_dec, currency)
449                .unwrap_or_else(|_| Money::zero(currency)),
450            None,
451        ));
452    }
453
454    let account_type = AccountType::Margin;
455    let is_reported = true;
456    let event_id = UUID4::new();
457    let ts_event =
458        UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
459
460    Ok(AccountState::new(
461        account_id,
462        account_type,
463        balances,
464        margins,
465        is_reported,
466        event_id,
467        ts_event,
468        ts_init,
469        None,
470    ))
471}
472
473/// Extracts the peg price type from order command parameters.
474///
475/// # Errors
476///
477/// Returns an error if the value is present but not a valid `BitmexPegPriceType`.
478pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
479    let value = params.and_then(|p| p.get_str("peg_price_type"));
480    match value {
481        Some(s) => BitmexPegPriceType::from_str(s)
482            .map(Some)
483            .map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
484        None => Ok(None),
485    }
486}
487
488/// Extracts the peg offset value from order command parameters.
489///
490/// # Errors
491///
492/// Returns an error if the value is present but not a valid `f64`.
493pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
494    let value = params.and_then(|p| p.get_str("peg_offset_value"));
495    match value {
496        Some(s) => s
497            .parse::<f64>()
498            .map(Some)
499            .map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
500        None => Ok(None),
501    }
502}
503
504/// Derives a deterministic [`TradeId`] for BitMEX trades that arrive without a
505/// `trdMatchID` (e.g. certain historical or bucketed rows).
506///
507/// The hash combines the symbol, timestamp, price, size and side so replayed
508/// data yields the same identifier across runs. FNV-1a is stable across
509/// architectures and crate versions; the 0x1f delimiter keeps variable-length
510/// fields from colliding.
511#[must_use]
512pub fn derive_trade_id(
513    symbol: Ustr,
514    ts_event_ns: u64,
515    price: f64,
516    size: i64,
517    side: Option<BitmexSide>,
518) -> TradeId {
519    let side_tag: &[u8] = match side {
520        Some(BitmexSide::Buy) => b"B",
521        Some(BitmexSide::Sell) => b"S",
522        None => b"N",
523    };
524
525    let mut hash: u64 = FNV_OFFSET_BASIS;
526
527    for bytes in [
528        symbol.as_str().as_bytes(),
529        b"\x1f",
530        &ts_event_ns.to_le_bytes(),
531        b"\x1f",
532        &price.to_bits().to_le_bytes(),
533        b"\x1f",
534        &size.to_le_bytes(),
535        b"\x1f",
536        side_tag,
537    ] {
538        for &byte in bytes {
539            hash ^= u64::from(byte);
540            hash = hash.wrapping_mul(FNV_PRIME);
541        }
542    }
543    TradeId::new(format!("{hash:016x}"))
544}
545
546#[cfg(test)]
547mod tests {
548    use chrono::TimeZone;
549    use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
550    use rstest::rstest;
551    use ustr::Ustr;
552
553    use super::*;
554
555    #[rstest]
556    fn test_clean_reason_strips_nautilus_trader() {
557        assert_eq!(
558            clean_reason(
559                "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
560            ),
561            "Canceled: Order had execInst of ParticipateDoNotInitiate"
562        );
563
564        assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
565        assert_eq!(
566            clean_reason("Multiple lines\nSome content\nNautilusTrader"),
567            "Multiple lines\nSome content"
568        );
569        assert_eq!(clean_reason("No identifier here"), "No identifier here");
570        assert_eq!(clean_reason("  \nNautilusTrader  "), "");
571    }
572
573    #[rstest]
574    fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
575        let first = derive_trade_id(
576            Ustr::from("XBTUSD"),
577            1_700_000_000_000_000_000,
578            98_570.9,
579            100,
580            Some(BitmexSide::Buy),
581        );
582        let second = derive_trade_id(
583            Ustr::from("XBTUSD"),
584            1_700_000_000_000_000_000,
585            98_570.9,
586            100,
587            Some(BitmexSide::Buy),
588        );
589        assert_eq!(first, second);
590        assert_eq!(first.as_str().len(), 16);
591    }
592
593    #[rstest]
594    #[case::symbol_changed(derive_trade_id(
595        Ustr::from("ETHUSD"),
596        1,
597        100.0,
598        1,
599        Some(BitmexSide::Buy)
600    ))]
601    #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 100.0, 1, Some(BitmexSide::Buy)))]
602    #[case::price_changed(derive_trade_id(
603        Ustr::from("XBTUSD"),
604        1,
605        101.0,
606        1,
607        Some(BitmexSide::Buy)
608    ))]
609    #[case::size_changed(derive_trade_id(
610        Ustr::from("XBTUSD"),
611        1,
612        100.0,
613        2,
614        Some(BitmexSide::Buy)
615    ))]
616    #[case::side_changed(derive_trade_id(
617        Ustr::from("XBTUSD"),
618        1,
619        100.0,
620        1,
621        Some(BitmexSide::Sell)
622    ))]
623    #[case::side_missing(derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, None))]
624    fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
625        let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, Some(BitmexSide::Buy));
626        assert_ne!(baseline, altered);
627    }
628
629    #[rstest]
630    fn test_derive_trade_id_field_delimiter_prevents_collision() {
631        // Without the 0x1f delimiter between symbol and the remaining bytes,
632        // these two inputs would produce the same byte stream because
633        // `Ustr::from("A")` + `1u64` bytes == `Ustr::from("A\0\0\0\0\0\0\0\0")` + `0u64` bytes.
634        let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0, Some(BitmexSide::Buy));
635        let b = derive_trade_id(Ustr::from("A\0"), 256, 0.0, 0, Some(BitmexSide::Buy));
636        assert_ne!(a, b);
637    }
638
639    fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
640        let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
641        let raw_symbol = Symbol::from("SOLUSDT");
642        let base_currency = Currency::from("SOL");
643        let quote_currency = Currency::from("USDT");
644        let price_precision = 2;
645        let price_increment = Price::new(0.01, price_precision);
646        let size_increment = Quantity::new(size_increment, size_precision);
647        let instrument = CurrencyPair::new(
648            instrument_id,
649            raw_symbol,
650            base_currency,
651            quote_currency,
652            price_precision,
653            size_precision,
654            price_increment,
655            size_increment,
656            None, // multiplier
657            None, // lot_size
658            None, // max_quantity
659            None, // min_quantity
660            None, // max_notional
661            None, // min_notional
662            None, // max_price
663            None, // min_price
664            None, // margin_init
665            None, // margin_maint
666            None, // maker_fee
667            None, // taker_fee
668            None, // info
669            UnixNanos::from(0),
670            UnixNanos::from(0),
671        );
672        InstrumentAny::CurrencyPair(instrument)
673    }
674
675    #[rstest]
676    fn test_quantity_to_u32_scaled() {
677        let instrument = make_test_spot_instrument(0.0001, 4);
678        let qty = Quantity::new(0.1, 4);
679        assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
680    }
681
682    #[rstest]
683    fn test_parse_contracts_quantity_scaled() {
684        let instrument = make_test_spot_instrument(0.0001, 4);
685        let qty = parse_contracts_quantity(1_000, &instrument);
686        assert!((qty.as_f64() - 0.1).abs() < 1e-9);
687        assert_eq!(qty.precision, 4);
688    }
689
690    #[rstest]
691    fn test_convert_contract_quantity_scaling() {
692        let max_scale = FIXED_PRECISION as u32;
693        let (contract_decimal, size_increment) =
694            derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
695        assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
696
697        let lot_qty =
698            convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
699                .unwrap()
700                .unwrap();
701        assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
702        assert_eq!(lot_qty.precision, 1);
703    }
704
705    #[rstest]
706    fn test_derive_contract_decimal_defaults_to_one() {
707        let max_scale = FIXED_PRECISION as u32;
708        let (contract_decimal, size_increment) =
709            derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
710        assert_eq!(contract_decimal, Decimal::ONE);
711        assert_eq!(size_increment.as_f64(), 1.0);
712    }
713
714    #[rstest]
715    fn test_parse_account_state() {
716        let margin_msg = BitmexMarginMsg {
717            account: 123456,
718            currency: Ustr::from("XBt"),
719            risk_limit: Some(1000000000),
720            amount: Some(5000000),
721            prev_realised_pnl: Some(100000),
722            gross_comm: Some(1000),
723            gross_open_cost: Some(200000),
724            gross_open_premium: None,
725            gross_exec_cost: None,
726            gross_mark_value: Some(210000),
727            risk_value: Some(50000),
728            init_margin: Some(20000),
729            maint_margin: Some(10000),
730            target_excess_margin: Some(5000),
731            realised_pnl: Some(100000),
732            unrealised_pnl: Some(10000),
733            wallet_balance: Some(5000000),
734            margin_balance: Some(5010000),
735            margin_leverage: Some(2.5),
736            margin_used_pcnt: Some(0.25),
737            excess_margin: Some(4990000),
738            available_margin: Some(4980000),
739            withdrawable_margin: Some(4900000),
740            maker_fee_discount: Some(0.1),
741            taker_fee_discount: Some(0.05),
742            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
743            foreign_margin_balance: None,
744            foreign_requirement: None,
745        };
746
747        let account_id = AccountId::new("BITMEX-001");
748        let ts_init = UnixNanos::from(1_000_000_000);
749
750        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
751
752        assert_eq!(account_state.account_id, account_id);
753        assert_eq!(account_state.account_type, AccountType::Margin);
754        assert_eq!(account_state.balances.len(), 1);
755        assert_eq!(account_state.margins.len(), 1);
756        assert!(account_state.is_reported);
757
758        let xbt_balance = &account_state.balances[0];
759        assert_eq!(xbt_balance.currency, Currency::from("XBT"));
760        assert_eq!(xbt_balance.total.as_f64(), 0.05); // 5000000 satoshis = 0.05 XBT wallet balance
761        assert_eq!(xbt_balance.free.as_f64(), 0.049); // 4900000 satoshis = 0.049 XBT withdrawable
762        assert_eq!(xbt_balance.locked.as_f64(), 0.001); // 100000 satoshis locked
763
764        let xbt_margin = &account_state.margins[0];
765        assert_eq!(xbt_margin.initial.as_f64(), 0.0002); // 20000 satoshis
766        assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); // 10000 satoshis
767    }
768
769    #[rstest]
770    fn test_parse_account_state_usdt() {
771        let margin_msg = BitmexMarginMsg {
772            account: 123456,
773            currency: Ustr::from("USDt"),
774            risk_limit: Some(1000000000),
775            amount: Some(10000000000), // 10000 USDT in microunits
776            prev_realised_pnl: None,
777            gross_comm: None,
778            gross_open_cost: None,
779            gross_open_premium: None,
780            gross_exec_cost: None,
781            gross_mark_value: None,
782            risk_value: None,
783            init_margin: Some(500000),  // 0.5 USDT in microunits
784            maint_margin: Some(250000), // 0.25 USDT in microunits
785            target_excess_margin: None,
786            realised_pnl: None,
787            unrealised_pnl: None,
788            wallet_balance: Some(10000000000),
789            margin_balance: Some(10000000000),
790            margin_leverage: None,
791            margin_used_pcnt: None,
792            excess_margin: None,
793            available_margin: Some(9500000000), // 9500 USDT available
794            withdrawable_margin: None,
795            maker_fee_discount: None,
796            taker_fee_discount: None,
797            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
798            foreign_margin_balance: None,
799            foreign_requirement: None,
800        };
801
802        let account_id = AccountId::new("BITMEX-001");
803        let ts_init = UnixNanos::from(1_000_000_000);
804
805        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
806
807        let usdt_balance = &account_state.balances[0];
808        assert_eq!(usdt_balance.currency, Currency::USDT());
809        assert_eq!(usdt_balance.total.as_f64(), 10000.0);
810        assert_eq!(usdt_balance.free.as_f64(), 9500.0);
811        assert_eq!(usdt_balance.locked.as_f64(), 500.0);
812
813        assert_eq!(account_state.margins.len(), 1);
814        let usdt_margin = &account_state.margins[0];
815        assert_eq!(usdt_margin.initial.as_f64(), 0.5); // 500000 microunits
816        assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); // 250000 microunits
817    }
818
819    #[rstest]
820    fn test_parse_account_balance_falls_back_to_margin_balance_when_wallet_absent() {
821        // Exercises the second rung of the fallback chain in `parse_account_balance`
822        // (wallet_balance → margin_balance → available_margin → 0). Without this
823        // test a swap that skipped the margin_balance branch silently passes.
824        let margin_msg = BitmexMarginMsg {
825            account: 123456,
826            currency: Ustr::from("XBt"),
827            risk_limit: None,
828            amount: None,
829            prev_realised_pnl: None,
830            gross_comm: None,
831            gross_open_cost: None,
832            gross_open_premium: None,
833            gross_exec_cost: None,
834            gross_mark_value: None,
835            risk_value: None,
836            init_margin: Some(20000),
837            maint_margin: Some(10000),
838            target_excess_margin: None,
839            realised_pnl: None,
840            unrealised_pnl: None,
841            wallet_balance: None,
842            margin_balance: Some(5_010_000),
843            margin_leverage: None,
844            margin_used_pcnt: None,
845            excess_margin: None,
846            available_margin: Some(4_980_000),
847            withdrawable_margin: Some(4_900_000),
848            maker_fee_discount: None,
849            taker_fee_discount: None,
850            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
851            foreign_margin_balance: None,
852            foreign_requirement: None,
853        };
854
855        let balance = parse_account_balance(&margin_msg);
856
857        assert_eq!(balance.currency, Currency::from("XBT"));
858        // total sourced from margin_balance (5_010_000 satoshis = 0.0501 XBT)
859        assert!((balance.total.as_f64() - 0.0501).abs() < 1e-9);
860        // free preferred from withdrawable_margin (4_900_000 satoshis = 0.049 XBT)
861        assert!((balance.free.as_f64() - 0.049).abs() < 1e-9);
862        // locked derived centrally as total − free = 0.0011 XBT
863        assert!((balance.locked.as_f64() - 0.0011).abs() < 1e-9);
864    }
865
866    #[rstest]
867    fn test_parse_margin_message_with_missing_fields() {
868        // Create a margin message with missing optional fields
869        let margin_msg = BitmexMarginMsg {
870            account: 123456,
871            currency: Ustr::from("XBt"),
872            risk_limit: None,
873            amount: None,
874            prev_realised_pnl: None,
875            gross_comm: None,
876            gross_open_cost: None,
877            gross_open_premium: None,
878            gross_exec_cost: None,
879            gross_mark_value: None,
880            risk_value: None,
881            init_margin: None,  // Missing
882            maint_margin: None, // Missing
883            target_excess_margin: None,
884            realised_pnl: None,
885            unrealised_pnl: None,
886            wallet_balance: Some(100000),
887            margin_balance: None,
888            margin_leverage: None,
889            margin_used_pcnt: None,
890            excess_margin: None,
891            available_margin: Some(95000),
892            withdrawable_margin: None,
893            maker_fee_discount: None,
894            taker_fee_discount: None,
895            timestamp: chrono::Utc::now(),
896            foreign_margin_balance: None,
897            foreign_requirement: None,
898        };
899
900        let account_id = AccountId::new("BITMEX-123456");
901        let ts_init = UnixNanos::from(1_000_000_000);
902
903        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
904            .expect("Should parse even with missing margin fields");
905
906        // Should have balance but no margins
907        assert_eq!(account_state.balances.len(), 1);
908        assert_eq!(account_state.margins.len(), 0); // No margins tracked
909    }
910
911    #[rstest]
912    fn test_parse_margin_message_with_only_available_margin() {
913        // This is the case we saw in the logs - only available_margin is populated
914        let margin_msg = BitmexMarginMsg {
915            account: 1667725,
916            currency: Ustr::from("USDt"),
917            risk_limit: None,
918            amount: None,
919            prev_realised_pnl: None,
920            gross_comm: None,
921            gross_open_cost: None,
922            gross_open_premium: None,
923            gross_exec_cost: None,
924            gross_mark_value: None,
925            risk_value: None,
926            init_margin: None,
927            maint_margin: None,
928            target_excess_margin: None,
929            realised_pnl: None,
930            unrealised_pnl: None,
931            wallet_balance: None, // None
932            margin_balance: None, // None
933            margin_leverage: None,
934            margin_used_pcnt: None,
935            excess_margin: None,
936            available_margin: Some(107859036), // Only this is populated
937            withdrawable_margin: None,
938            maker_fee_discount: None,
939            taker_fee_discount: None,
940            timestamp: chrono::Utc::now(),
941            foreign_margin_balance: None,
942            foreign_requirement: None,
943        };
944
945        let account_id = AccountId::new("BITMEX-1667725");
946        let ts_init = UnixNanos::from(1_000_000_000);
947
948        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
949            .expect("Should handle case with only available_margin");
950
951        // Check the balance accounting equation holds
952        let balance = &account_state.balances[0];
953        assert_eq!(balance.currency, Currency::USDT());
954        assert_eq!(balance.total.as_f64(), 107.859036); // Total should equal free when only available_margin is present
955        assert_eq!(balance.free.as_f64(), 107.859036);
956        assert_eq!(balance.locked.as_f64(), 0.0);
957
958        // Verify the accounting equation: total = locked + free
959        assert_eq!(balance.total, balance.locked + balance.free);
960    }
961
962    #[rstest]
963    fn test_parse_margin_available_exceeds_wallet() {
964        // Test case where available margin exceeds wallet balance (bonus margin scenario)
965        let margin_msg = BitmexMarginMsg {
966            account: 123456,
967            currency: Ustr::from("XBt"),
968            risk_limit: None,
969            amount: Some(70772),
970            prev_realised_pnl: None,
971            gross_comm: None,
972            gross_open_cost: None,
973            gross_open_premium: None,
974            gross_exec_cost: None,
975            gross_mark_value: None,
976            risk_value: None,
977            init_margin: Some(0),
978            maint_margin: Some(0),
979            target_excess_margin: None,
980            realised_pnl: None,
981            unrealised_pnl: None,
982            wallet_balance: Some(70772), // 0.00070772 BTC
983            margin_balance: None,
984            margin_leverage: None,
985            margin_used_pcnt: None,
986            excess_margin: None,
987            available_margin: Some(94381), // 0.00094381 BTC - exceeds wallet!
988            withdrawable_margin: None,
989            maker_fee_discount: None,
990            taker_fee_discount: None,
991            timestamp: chrono::Utc::now(),
992            foreign_margin_balance: None,
993            foreign_requirement: None,
994        };
995
996        let account_id = AccountId::new("BITMEX-123456");
997        let ts_init = UnixNanos::from(1_000_000_000);
998
999        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1000            .expect("Should handle available > wallet case");
1001
1002        // Wallet balance is the actual asset amount, not available margin
1003        let balance = &account_state.balances[0];
1004        assert_eq!(balance.currency, Currency::from("XBT"));
1005        assert_eq!(balance.total.as_f64(), 0.00070772); // Wallet balance (actual assets)
1006        assert_eq!(balance.free.as_f64(), 0.00070772); // All free since no margin locked
1007        assert_eq!(balance.locked.as_f64(), 0.0);
1008
1009        // Verify the accounting equation: total = locked + free
1010        assert_eq!(balance.total, balance.locked + balance.free);
1011    }
1012
1013    #[rstest]
1014    fn test_parse_margin_message_with_foreign_requirements() {
1015        // Test case where trading USDT-settled contracts with XBT margin
1016        let margin_msg = BitmexMarginMsg {
1017            account: 123456,
1018            currency: Ustr::from("XBt"),
1019            risk_limit: Some(1000000000),
1020            amount: Some(100000000), // 1 BTC
1021            prev_realised_pnl: None,
1022            gross_comm: None,
1023            gross_open_cost: None,
1024            gross_open_premium: None,
1025            gross_exec_cost: None,
1026            gross_mark_value: None,
1027            risk_value: None,
1028            init_margin: None,  // No direct margin in XBT
1029            maint_margin: None, // No direct margin in XBT
1030            target_excess_margin: None,
1031            realised_pnl: None,
1032            unrealised_pnl: None,
1033            wallet_balance: Some(100000000),
1034            margin_balance: Some(100000000),
1035            margin_leverage: None,
1036            margin_used_pcnt: None,
1037            excess_margin: None,
1038            available_margin: Some(95000000), // 0.95 BTC available
1039            withdrawable_margin: None,
1040            maker_fee_discount: None,
1041            taker_fee_discount: None,
1042            timestamp: chrono::Utc::now(),
1043            foreign_margin_balance: Some(100000000), // Foreign margin balance in satoshis
1044            foreign_requirement: Some(5000000),      // 0.05 BTC required for USDT positions
1045        };
1046
1047        let account_id = AccountId::new("BITMEX-123456");
1048        let ts_init = UnixNanos::from(1_000_000_000);
1049
1050        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1051            .expect("Failed to parse account state with foreign requirements");
1052
1053        // Check balance
1054        let balance = &account_state.balances[0];
1055        assert_eq!(balance.currency, Currency::from("XBT"));
1056        assert_eq!(balance.total.as_f64(), 1.0);
1057        assert_eq!(balance.free.as_f64(), 0.95);
1058        assert_eq!(balance.locked.as_f64(), 0.05);
1059
1060        // No margins tracked
1061        assert_eq!(account_state.margins.len(), 0);
1062    }
1063
1064    #[rstest]
1065    fn test_parse_margin_message_with_both_standard_and_foreign() {
1066        // Test case with both standard and foreign margin requirements
1067        let margin_msg = BitmexMarginMsg {
1068            account: 123456,
1069            currency: Ustr::from("XBt"),
1070            risk_limit: Some(1000000000),
1071            amount: Some(100000000), // 1 BTC
1072            prev_realised_pnl: None,
1073            gross_comm: None,
1074            gross_open_cost: None,
1075            gross_open_premium: None,
1076            gross_exec_cost: None,
1077            gross_mark_value: None,
1078            risk_value: None,
1079            init_margin: Some(2000000),  // 0.02 BTC for XBT positions
1080            maint_margin: Some(1000000), // 0.01 BTC for XBT positions
1081            target_excess_margin: None,
1082            realised_pnl: None,
1083            unrealised_pnl: None,
1084            wallet_balance: Some(100000000),
1085            margin_balance: Some(100000000),
1086            margin_leverage: None,
1087            margin_used_pcnt: None,
1088            excess_margin: None,
1089            available_margin: Some(93000000), // 0.93 BTC available
1090            withdrawable_margin: None,
1091            maker_fee_discount: None,
1092            taker_fee_discount: None,
1093            timestamp: chrono::Utc::now(),
1094            foreign_margin_balance: Some(100000000),
1095            foreign_requirement: Some(5000000), // 0.05 BTC for USDT positions
1096        };
1097
1098        let account_id = AccountId::new("BITMEX-123456");
1099        let ts_init = UnixNanos::from(1_000_000_000);
1100
1101        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1102            .expect("Failed to parse account state with both margins");
1103
1104        // Check balance
1105        let balance = &account_state.balances[0];
1106        assert_eq!(balance.currency, Currency::from("XBT"));
1107        assert_eq!(balance.total.as_f64(), 1.0);
1108        assert_eq!(balance.free.as_f64(), 0.93);
1109        assert_eq!(balance.locked.as_f64(), 0.07); // 0.02 + 0.05 = 0.07 total margin
1110
1111        assert_eq!(account_state.margins.len(), 1);
1112        let xbt_margin = &account_state.margins[0];
1113        assert_eq!(xbt_margin.initial.as_f64(), 0.02); // 2000000 satoshis
1114        assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); // 1000000 satoshis
1115    }
1116}