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/// - "USYc" -> "USYC" (testnet stablecoin)
343///
344/// For other currencies, converts to uppercase.
345#[must_use]
346pub fn map_bitmex_currency(bitmex_currency: &str) -> Cow<'static, str> {
347    match bitmex_currency {
348        "XBt" => Cow::Borrowed("XBT"),
349        "USDt" | "LAMp" => Cow::Borrowed("USDT"), // LAMp is test currency
350        "RLUSd" => Cow::Borrowed("RLUSD"),
351        "MAMUSd" => Cow::Borrowed("MAMUSD"),
352        other => Cow::Owned(other.to_uppercase()),
353    }
354}
355
356/// Returns the Decimal divisor for converting BitMEX raw integer units to standard units.
357#[must_use]
358pub fn bitmex_currency_divisor(bitmex_currency: &str) -> Decimal {
359    match bitmex_currency {
360        "XBt" => Decimal::from(100_000_000),
361        "USDt" | "LAMp" | "MAMUSd" | "RLUSd" | "USYc" | "USYC" => Decimal::from(1_000_000),
362        _ => Decimal::ONE,
363    }
364}
365
366/// Returns the Nautilus account ID for a BitMEX account number.
367#[must_use]
368pub fn bitmex_account_id(account: i64) -> AccountId {
369    AccountId::new(format!("BITMEX-{account}"))
370}
371
372/// Parses a BitMEX margin message into a Nautilus account balance.
373pub fn parse_account_balance(margin: &BitmexMarginMsg) -> AccountBalance {
374    log::debug!(
375        "Parsing margin: currency={}, wallet_balance={:?}, available_margin={:?}, init_margin={:?}, maint_margin={:?}",
376        margin.currency,
377        margin.wallet_balance,
378        margin.available_margin,
379        margin.init_margin,
380        margin.maint_margin,
381    );
382
383    let currency_str = map_bitmex_currency(&margin.currency);
384    let currency = parse_bitmex_margin_currency(&currency_str);
385
386    // BitMEX returns values in satoshis for BTC (XBt) or microunits for stablecoins.
387    let divisor = bitmex_currency_divisor(margin.currency.as_str());
388    let to_dec = |raw: i64| Decimal::from(raw) / divisor;
389
390    // Wallet balance is the actual asset amount. Fall back progressively.
391    let total_dec = margin
392        .wallet_balance
393        .map(to_dec)
394        .or_else(|| margin.margin_balance.map(to_dec))
395        .or_else(|| margin.available_margin.map(to_dec))
396        .unwrap_or(Decimal::ZERO);
397
398    // Free balance: prefer withdrawable_margin, then available_margin, then
399    // derive as `total - init_margin`. `from_total_and_free` clamps `free`
400    // into `[0, total]` for non-negative totals, so no manual clamping here.
401    let free_dec = if let Some(withdrawable) = margin.withdrawable_margin {
402        to_dec(withdrawable)
403    } else if let Some(available) = margin.available_margin {
404        to_dec(available)
405    } else {
406        let margin_used = margin.init_margin.map_or(Decimal::ZERO, to_dec);
407        total_dec - margin_used
408    };
409
410    AccountBalance::from_total_and_free(total_dec, free_dec, currency).unwrap_or_else(|e| {
411        log::error!("Failed to build BitMEX account balance: {e}");
412        let zero = Money::zero(currency);
413        AccountBalance::new(zero, zero, zero)
414    })
415}
416
417fn parse_bitmex_margin_currency(currency_str: &str) -> Currency {
418    if let Some(currency) = known_bitmex_margin_currency(currency_str) {
419        return currency;
420    }
421
422    match Currency::try_from_str(currency_str) {
423        Some(c) => c,
424        None => {
425            log::warn!(
426                "Unknown currency '{currency_str}' in margin message, creating default crypto currency"
427            );
428            let currency = Currency::new(currency_str, 8, 0, currency_str, CurrencyType::Crypto);
429            if let Err(e) = Currency::register(currency, false) {
430                log::error!("Failed to register currency '{currency_str}': {e}");
431            }
432            currency
433        }
434    }
435}
436
437fn known_bitmex_margin_currency(currency_str: &str) -> Option<Currency> {
438    match currency_str {
439        "USYC" => {
440            let currency = Currency::new("USYC", 6, 0, "USYC", CurrencyType::Crypto);
441            if let Err(e) = Currency::register(currency, true) {
442                log::error!("Failed to register currency '{currency_str}': {e}");
443            }
444            Some(currency)
445        }
446        _ => None,
447    }
448}
449
450/// Parses a BitMEX margin message into a Nautilus account state.
451///
452/// # Errors
453///
454/// Returns an error if the margin data cannot be parsed into valid balance values.
455pub fn parse_account_state(
456    margin: &BitmexMarginMsg,
457    account_id: AccountId,
458    ts_init: UnixNanos,
459) -> anyhow::Result<AccountState> {
460    let balance = parse_account_balance(margin);
461    let balances = vec![balance];
462
463    let currency = balance.total.currency;
464    let mut margins = Vec::new();
465
466    let divisor = bitmex_currency_divisor(margin.currency.as_str());
467    let initial_dec = Decimal::from(margin.init_margin.unwrap_or(0).max(0)) / divisor;
468    let maintenance_dec = Decimal::from(margin.maint_margin.unwrap_or(0).max(0)) / divisor;
469
470    if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
471        // BitMEX reports cross-margin aggregates per collateral currency.
472        margins.push(MarginBalance::new(
473            Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
474            Money::from_decimal(maintenance_dec, currency)
475                .unwrap_or_else(|_| Money::zero(currency)),
476            None,
477        ));
478    }
479
480    let account_type = AccountType::Margin;
481    let is_reported = true;
482    let event_id = UUID4::new();
483    let ts_event =
484        UnixNanos::from(margin.timestamp.timestamp_nanos_opt().unwrap_or_default() as u64);
485
486    Ok(AccountState::new(
487        account_id,
488        account_type,
489        balances,
490        margins,
491        is_reported,
492        event_id,
493        ts_event,
494        ts_init,
495        None,
496    ))
497}
498
499/// Extracts the peg price type from order command parameters.
500///
501/// # Errors
502///
503/// Returns an error if the value is present but not a valid `BitmexPegPriceType`.
504pub fn parse_peg_price_type(params: Option<&Params>) -> anyhow::Result<Option<BitmexPegPriceType>> {
505    let value = params.and_then(|p| p.get_str("peg_price_type"));
506    match value {
507        Some(s) => BitmexPegPriceType::from_str(s)
508            .map(Some)
509            .map_err(|_| anyhow::anyhow!("Invalid peg_price_type: {s}")),
510        None => Ok(None),
511    }
512}
513
514/// Extracts the peg offset value from order command parameters.
515///
516/// # Errors
517///
518/// Returns an error if the value is present but not a valid `f64`.
519pub fn parse_peg_offset_value(params: Option<&Params>) -> anyhow::Result<Option<f64>> {
520    let value = params.and_then(|p| p.get_str("peg_offset_value"));
521    match value {
522        Some(s) => s
523            .parse::<f64>()
524            .map(Some)
525            .map_err(|_| anyhow::anyhow!("Invalid peg_offset_value: {s}")),
526        None => Ok(None),
527    }
528}
529
530/// Derives a deterministic [`TradeId`] for BitMEX trades that arrive without a
531/// `trdMatchID` (e.g. certain historical or bucketed rows).
532///
533/// The hash combines the symbol, timestamp, price, size and side so replayed
534/// data yields the same identifier across runs. FNV-1a is stable across
535/// architectures and crate versions; the 0x1f delimiter keeps variable-length
536/// fields from colliding.
537#[must_use]
538pub fn derive_trade_id(
539    symbol: Ustr,
540    ts_event_ns: u64,
541    price: f64,
542    size: i64,
543    side: Option<BitmexSide>,
544) -> TradeId {
545    let side_tag: &[u8] = match side {
546        Some(BitmexSide::Buy) => b"B",
547        Some(BitmexSide::Sell) => b"S",
548        None => b"N",
549    };
550
551    let mut hash: u64 = FNV_OFFSET_BASIS;
552
553    for bytes in [
554        symbol.as_str().as_bytes(),
555        b"\x1f",
556        &ts_event_ns.to_le_bytes(),
557        b"\x1f",
558        &price.to_bits().to_le_bytes(),
559        b"\x1f",
560        &size.to_le_bytes(),
561        b"\x1f",
562        side_tag,
563    ] {
564        for &byte in bytes {
565            hash ^= u64::from(byte);
566            hash = hash.wrapping_mul(FNV_PRIME);
567        }
568    }
569    TradeId::new(format!("{hash:016x}"))
570}
571
572#[cfg(test)]
573mod tests {
574    use chrono::TimeZone;
575    use nautilus_model::{instruments::CurrencyPair, types::fixed::FIXED_PRECISION};
576    use rstest::rstest;
577    use ustr::Ustr;
578
579    use super::*;
580
581    #[rstest]
582    fn test_clean_reason_strips_nautilus_trader() {
583        assert_eq!(
584            clean_reason(
585                "Canceled: Order had execInst of ParticipateDoNotInitiate\nNautilusTrader"
586            ),
587            "Canceled: Order had execInst of ParticipateDoNotInitiate"
588        );
589
590        assert_eq!(clean_reason("Some error\nNautilusTrader"), "Some error");
591        assert_eq!(
592            clean_reason("Multiple lines\nSome content\nNautilusTrader"),
593            "Multiple lines\nSome content"
594        );
595        assert_eq!(clean_reason("No identifier here"), "No identifier here");
596        assert_eq!(clean_reason("  \nNautilusTrader  "), "");
597    }
598
599    #[rstest]
600    fn test_derive_trade_id_is_deterministic_and_16_hex_chars() {
601        let first = derive_trade_id(
602            Ustr::from("XBTUSD"),
603            1_700_000_000_000_000_000,
604            98_570.9,
605            100,
606            Some(BitmexSide::Buy),
607        );
608        let second = derive_trade_id(
609            Ustr::from("XBTUSD"),
610            1_700_000_000_000_000_000,
611            98_570.9,
612            100,
613            Some(BitmexSide::Buy),
614        );
615        assert_eq!(first, second);
616        assert_eq!(first.as_str().len(), 16);
617    }
618
619    #[rstest]
620    #[case::symbol_changed(derive_trade_id(
621        Ustr::from("ETHUSD"),
622        1,
623        100.0,
624        1,
625        Some(BitmexSide::Buy)
626    ))]
627    #[case::ts_changed(derive_trade_id(Ustr::from("XBTUSD"), 2, 100.0, 1, Some(BitmexSide::Buy)))]
628    #[case::price_changed(derive_trade_id(
629        Ustr::from("XBTUSD"),
630        1,
631        101.0,
632        1,
633        Some(BitmexSide::Buy)
634    ))]
635    #[case::size_changed(derive_trade_id(
636        Ustr::from("XBTUSD"),
637        1,
638        100.0,
639        2,
640        Some(BitmexSide::Buy)
641    ))]
642    #[case::side_changed(derive_trade_id(
643        Ustr::from("XBTUSD"),
644        1,
645        100.0,
646        1,
647        Some(BitmexSide::Sell)
648    ))]
649    #[case::side_missing(derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, None))]
650    fn test_derive_trade_id_each_field_affects_output(#[case] altered: TradeId) {
651        let baseline = derive_trade_id(Ustr::from("XBTUSD"), 1, 100.0, 1, Some(BitmexSide::Buy));
652        assert_ne!(baseline, altered);
653    }
654
655    #[rstest]
656    fn test_derive_trade_id_field_delimiter_prevents_collision() {
657        // Without the 0x1f delimiter between symbol and the remaining bytes,
658        // these two inputs would produce the same byte stream because
659        // `Ustr::from("A")` + `1u64` bytes == `Ustr::from("A\0\0\0\0\0\0\0\0")` + `0u64` bytes.
660        let a = derive_trade_id(Ustr::from("A"), 1, 0.0, 0, Some(BitmexSide::Buy));
661        let b = derive_trade_id(Ustr::from("A\0"), 256, 0.0, 0, Some(BitmexSide::Buy));
662        assert_ne!(a, b);
663    }
664
665    fn make_test_spot_instrument(size_increment: f64, size_precision: u8) -> InstrumentAny {
666        let instrument_id = InstrumentId::from("SOLUSDT.BITMEX");
667        let raw_symbol = Symbol::from("SOLUSDT");
668        let base_currency = Currency::from("SOL");
669        let quote_currency = Currency::from("USDT");
670        let price_precision = 2;
671        let price_increment = Price::new(0.01, price_precision);
672        let size_increment = Quantity::new(size_increment, size_precision);
673        let instrument = CurrencyPair::new(
674            instrument_id,
675            raw_symbol,
676            base_currency,
677            quote_currency,
678            price_precision,
679            size_precision,
680            price_increment,
681            size_increment,
682            None, // multiplier
683            None, // lot_size
684            None, // max_quantity
685            None, // min_quantity
686            None, // max_notional
687            None, // min_notional
688            None, // max_price
689            None, // min_price
690            None, // margin_init
691            None, // margin_maint
692            None, // maker_fee
693            None, // taker_fee
694            None, // tick_scheme
695            None, // info
696            UnixNanos::from(0),
697            UnixNanos::from(0),
698        );
699        InstrumentAny::CurrencyPair(instrument)
700    }
701
702    #[rstest]
703    fn test_quantity_to_u32_scaled() {
704        let instrument = make_test_spot_instrument(0.0001, 4);
705        let qty = Quantity::new(0.1, 4);
706        assert_eq!(quantity_to_u32(&qty, &instrument), 1_000);
707    }
708
709    #[rstest]
710    fn test_parse_contracts_quantity_scaled() {
711        let instrument = make_test_spot_instrument(0.0001, 4);
712        let qty = parse_contracts_quantity(1_000, &instrument);
713        assert!((qty.as_f64() - 0.1).abs() < 1e-9);
714        assert_eq!(qty.precision, 4);
715    }
716
717    #[rstest]
718    fn test_convert_contract_quantity_scaling() {
719        let max_scale = FIXED_PRECISION as u32;
720        let (contract_decimal, size_increment) =
721            derive_contract_decimal_and_increment(Some(10_000.0), max_scale).unwrap();
722        assert!((size_increment.as_f64() - 0.0001).abs() < 1e-12);
723
724        let lot_qty =
725            convert_contract_quantity(Some(1_000.0), contract_decimal, max_scale, "lot size")
726                .unwrap()
727                .unwrap();
728        assert!((lot_qty.as_f64() - 0.1).abs() < 1e-9);
729        assert_eq!(lot_qty.precision, 1);
730    }
731
732    #[rstest]
733    fn test_derive_contract_decimal_defaults_to_one() {
734        let max_scale = FIXED_PRECISION as u32;
735        let (contract_decimal, size_increment) =
736            derive_contract_decimal_and_increment(Some(0.0), max_scale).unwrap();
737        assert_eq!(contract_decimal, Decimal::ONE);
738        assert_eq!(size_increment.as_f64(), 1.0);
739    }
740
741    #[rstest]
742    fn test_parse_account_state() {
743        let margin_msg = BitmexMarginMsg {
744            account: 123456,
745            currency: Ustr::from("XBt"),
746            risk_limit: Some(1000000000),
747            amount: Some(5000000),
748            prev_realised_pnl: Some(100000),
749            gross_comm: Some(1000),
750            gross_open_cost: Some(200000),
751            gross_open_premium: None,
752            gross_exec_cost: None,
753            gross_mark_value: Some(210000),
754            risk_value: Some(50000),
755            init_margin: Some(20000),
756            maint_margin: Some(10000),
757            target_excess_margin: Some(5000),
758            realised_pnl: Some(100000),
759            unrealised_pnl: Some(10000),
760            wallet_balance: Some(5000000),
761            margin_balance: Some(5010000),
762            margin_leverage: Some(2.5),
763            margin_used_pcnt: Some(0.25),
764            excess_margin: Some(4990000),
765            available_margin: Some(4980000),
766            withdrawable_margin: Some(4900000),
767            maker_fee_discount: Some(0.1),
768            taker_fee_discount: Some(0.05),
769            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
770            foreign_margin_balance: None,
771            foreign_requirement: None,
772        };
773
774        let account_id = AccountId::new("BITMEX-001");
775        let ts_init = UnixNanos::from(1_000_000_000);
776
777        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
778
779        assert_eq!(account_state.account_id, account_id);
780        assert_eq!(account_state.account_type, AccountType::Margin);
781        assert_eq!(account_state.balances.len(), 1);
782        assert_eq!(account_state.margins.len(), 1);
783        assert!(account_state.is_reported);
784
785        let xbt_balance = &account_state.balances[0];
786        assert_eq!(xbt_balance.currency, Currency::from("XBT"));
787        assert_eq!(xbt_balance.total.as_f64(), 0.05); // 5000000 satoshis = 0.05 XBT wallet balance
788        assert_eq!(xbt_balance.free.as_f64(), 0.049); // 4900000 satoshis = 0.049 XBT withdrawable
789        assert_eq!(xbt_balance.locked.as_f64(), 0.001); // 100000 satoshis locked
790
791        let xbt_margin = &account_state.margins[0];
792        assert_eq!(xbt_margin.initial.as_f64(), 0.0002); // 20000 satoshis
793        assert_eq!(xbt_margin.maintenance.as_f64(), 0.0001); // 10000 satoshis
794    }
795
796    #[rstest]
797    fn test_parse_account_state_usdt() {
798        let margin_msg = BitmexMarginMsg {
799            account: 123456,
800            currency: Ustr::from("USDt"),
801            risk_limit: Some(1000000000),
802            amount: Some(10000000000), // 10000 USDT in microunits
803            prev_realised_pnl: None,
804            gross_comm: None,
805            gross_open_cost: None,
806            gross_open_premium: None,
807            gross_exec_cost: None,
808            gross_mark_value: None,
809            risk_value: None,
810            init_margin: Some(500000),  // 0.5 USDT in microunits
811            maint_margin: Some(250000), // 0.25 USDT in microunits
812            target_excess_margin: None,
813            realised_pnl: None,
814            unrealised_pnl: None,
815            wallet_balance: Some(10000000000),
816            margin_balance: Some(10000000000),
817            margin_leverage: None,
818            margin_used_pcnt: None,
819            excess_margin: None,
820            available_margin: Some(9500000000), // 9500 USDT available
821            withdrawable_margin: None,
822            maker_fee_discount: None,
823            taker_fee_discount: None,
824            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
825            foreign_margin_balance: None,
826            foreign_requirement: None,
827        };
828
829        let account_id = AccountId::new("BITMEX-001");
830        let ts_init = UnixNanos::from(1_000_000_000);
831
832        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
833
834        let usdt_balance = &account_state.balances[0];
835        assert_eq!(usdt_balance.currency, Currency::USDT());
836        assert_eq!(usdt_balance.total.as_f64(), 10000.0);
837        assert_eq!(usdt_balance.free.as_f64(), 9500.0);
838        assert_eq!(usdt_balance.locked.as_f64(), 500.0);
839
840        assert_eq!(account_state.margins.len(), 1);
841        let usdt_margin = &account_state.margins[0];
842        assert_eq!(usdt_margin.initial.as_f64(), 0.5); // 500000 microunits
843        assert_eq!(usdt_margin.maintenance.as_f64(), 0.25); // 250000 microunits
844    }
845
846    #[rstest]
847    fn test_parse_account_state_usyc_margin_currency() {
848        let margin_msg = BitmexMarginMsg {
849            account: 123456,
850            currency: Ustr::from("USYc"),
851            risk_limit: Some(1000000000),
852            amount: Some(100000000),
853            prev_realised_pnl: None,
854            gross_comm: None,
855            gross_open_cost: None,
856            gross_open_premium: None,
857            gross_exec_cost: None,
858            gross_mark_value: None,
859            risk_value: None,
860            init_margin: Some(500000),
861            maint_margin: Some(250000),
862            target_excess_margin: None,
863            realised_pnl: None,
864            unrealised_pnl: None,
865            wallet_balance: Some(100000000),
866            margin_balance: Some(100000000),
867            margin_leverage: None,
868            margin_used_pcnt: None,
869            excess_margin: None,
870            available_margin: Some(99000000),
871            withdrawable_margin: None,
872            maker_fee_discount: None,
873            taker_fee_discount: None,
874            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
875            foreign_margin_balance: None,
876            foreign_requirement: None,
877        };
878
879        let account_id = AccountId::new("BITMEX-001");
880        let ts_init = UnixNanos::from(1_000_000_000);
881
882        let account_state = parse_account_state(&margin_msg, account_id, ts_init).unwrap();
883
884        let balance = &account_state.balances[0];
885        assert_eq!(balance.currency.code.as_str(), "USYC");
886        assert_eq!(balance.currency.precision, 6);
887        assert_eq!(balance.total.as_f64(), 100.0);
888        assert_eq!(balance.free.as_f64(), 99.0);
889        assert_eq!(balance.locked.as_f64(), 1.0);
890
891        assert_eq!(account_state.margins.len(), 1);
892        let margin = &account_state.margins[0];
893        assert_eq!(margin.currency.code.as_str(), "USYC");
894        assert_eq!(margin.initial.as_f64(), 0.5);
895        assert_eq!(margin.maintenance.as_f64(), 0.25);
896    }
897
898    #[rstest]
899    fn test_parse_account_balance_falls_back_to_margin_balance_when_wallet_absent() {
900        // Exercises the second rung of the fallback chain in `parse_account_balance`
901        // (wallet_balance → margin_balance → available_margin → 0). Without this
902        // test a swap that skipped the margin_balance branch silently passes.
903        let margin_msg = BitmexMarginMsg {
904            account: 123456,
905            currency: Ustr::from("XBt"),
906            risk_limit: None,
907            amount: None,
908            prev_realised_pnl: None,
909            gross_comm: None,
910            gross_open_cost: None,
911            gross_open_premium: None,
912            gross_exec_cost: None,
913            gross_mark_value: None,
914            risk_value: None,
915            init_margin: Some(20000),
916            maint_margin: Some(10000),
917            target_excess_margin: None,
918            realised_pnl: None,
919            unrealised_pnl: None,
920            wallet_balance: None,
921            margin_balance: Some(5_010_000),
922            margin_leverage: None,
923            margin_used_pcnt: None,
924            excess_margin: None,
925            available_margin: Some(4_980_000),
926            withdrawable_margin: Some(4_900_000),
927            maker_fee_discount: None,
928            taker_fee_discount: None,
929            timestamp: chrono::Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
930            foreign_margin_balance: None,
931            foreign_requirement: None,
932        };
933
934        let balance = parse_account_balance(&margin_msg);
935
936        assert_eq!(balance.currency, Currency::from("XBT"));
937        // total sourced from margin_balance (5_010_000 satoshis = 0.0501 XBT)
938        assert!((balance.total.as_f64() - 0.0501).abs() < 1e-9);
939        // free preferred from withdrawable_margin (4_900_000 satoshis = 0.049 XBT)
940        assert!((balance.free.as_f64() - 0.049).abs() < 1e-9);
941        // locked derived centrally as total − free = 0.0011 XBT
942        assert!((balance.locked.as_f64() - 0.0011).abs() < 1e-9);
943    }
944
945    #[rstest]
946    fn test_parse_margin_message_with_missing_fields() {
947        // Create a margin message with missing optional fields
948        let margin_msg = BitmexMarginMsg {
949            account: 123456,
950            currency: Ustr::from("XBt"),
951            risk_limit: None,
952            amount: None,
953            prev_realised_pnl: None,
954            gross_comm: None,
955            gross_open_cost: None,
956            gross_open_premium: None,
957            gross_exec_cost: None,
958            gross_mark_value: None,
959            risk_value: None,
960            init_margin: None,  // Missing
961            maint_margin: None, // Missing
962            target_excess_margin: None,
963            realised_pnl: None,
964            unrealised_pnl: None,
965            wallet_balance: Some(100000),
966            margin_balance: None,
967            margin_leverage: None,
968            margin_used_pcnt: None,
969            excess_margin: None,
970            available_margin: Some(95000),
971            withdrawable_margin: None,
972            maker_fee_discount: None,
973            taker_fee_discount: None,
974            timestamp: chrono::Utc::now(),
975            foreign_margin_balance: None,
976            foreign_requirement: None,
977        };
978
979        let account_id = AccountId::new("BITMEX-123456");
980        let ts_init = UnixNanos::from(1_000_000_000);
981
982        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
983            .expect("Should parse even with missing margin fields");
984
985        // Should have balance but no margins
986        assert_eq!(account_state.balances.len(), 1);
987        assert_eq!(account_state.margins.len(), 0); // No margins tracked
988    }
989
990    #[rstest]
991    fn test_parse_margin_message_with_only_available_margin() {
992        // This is the case we saw in the logs - only available_margin is populated
993        let margin_msg = BitmexMarginMsg {
994            account: 1667725,
995            currency: Ustr::from("USDt"),
996            risk_limit: None,
997            amount: None,
998            prev_realised_pnl: None,
999            gross_comm: None,
1000            gross_open_cost: None,
1001            gross_open_premium: None,
1002            gross_exec_cost: None,
1003            gross_mark_value: None,
1004            risk_value: None,
1005            init_margin: None,
1006            maint_margin: None,
1007            target_excess_margin: None,
1008            realised_pnl: None,
1009            unrealised_pnl: None,
1010            wallet_balance: None, // None
1011            margin_balance: None, // None
1012            margin_leverage: None,
1013            margin_used_pcnt: None,
1014            excess_margin: None,
1015            available_margin: Some(107859036), // Only this is populated
1016            withdrawable_margin: None,
1017            maker_fee_discount: None,
1018            taker_fee_discount: None,
1019            timestamp: chrono::Utc::now(),
1020            foreign_margin_balance: None,
1021            foreign_requirement: None,
1022        };
1023
1024        let account_id = AccountId::new("BITMEX-1667725");
1025        let ts_init = UnixNanos::from(1_000_000_000);
1026
1027        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1028            .expect("Should handle case with only available_margin");
1029
1030        // Check the balance accounting equation holds
1031        let balance = &account_state.balances[0];
1032        assert_eq!(balance.currency, Currency::USDT());
1033        assert_eq!(balance.total.as_f64(), 107.859036); // Total should equal free when only available_margin is present
1034        assert_eq!(balance.free.as_f64(), 107.859036);
1035        assert_eq!(balance.locked.as_f64(), 0.0);
1036
1037        // Verify the accounting equation: total = locked + free
1038        assert_eq!(balance.total, balance.locked + balance.free);
1039    }
1040
1041    #[rstest]
1042    fn test_parse_margin_available_exceeds_wallet() {
1043        // Test case where available margin exceeds wallet balance (bonus margin scenario)
1044        let margin_msg = BitmexMarginMsg {
1045            account: 123456,
1046            currency: Ustr::from("XBt"),
1047            risk_limit: None,
1048            amount: Some(70772),
1049            prev_realised_pnl: None,
1050            gross_comm: None,
1051            gross_open_cost: None,
1052            gross_open_premium: None,
1053            gross_exec_cost: None,
1054            gross_mark_value: None,
1055            risk_value: None,
1056            init_margin: Some(0),
1057            maint_margin: Some(0),
1058            target_excess_margin: None,
1059            realised_pnl: None,
1060            unrealised_pnl: None,
1061            wallet_balance: Some(70772), // 0.00070772 BTC
1062            margin_balance: None,
1063            margin_leverage: None,
1064            margin_used_pcnt: None,
1065            excess_margin: None,
1066            available_margin: Some(94381), // 0.00094381 BTC - exceeds wallet!
1067            withdrawable_margin: None,
1068            maker_fee_discount: None,
1069            taker_fee_discount: None,
1070            timestamp: chrono::Utc::now(),
1071            foreign_margin_balance: None,
1072            foreign_requirement: None,
1073        };
1074
1075        let account_id = AccountId::new("BITMEX-123456");
1076        let ts_init = UnixNanos::from(1_000_000_000);
1077
1078        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1079            .expect("Should handle available > wallet case");
1080
1081        // Wallet balance is the actual asset amount, not available margin
1082        let balance = &account_state.balances[0];
1083        assert_eq!(balance.currency, Currency::from("XBT"));
1084        assert_eq!(balance.total.as_f64(), 0.00070772); // Wallet balance (actual assets)
1085        assert_eq!(balance.free.as_f64(), 0.00070772); // All free since no margin locked
1086        assert_eq!(balance.locked.as_f64(), 0.0);
1087
1088        // Verify the accounting equation: total = locked + free
1089        assert_eq!(balance.total, balance.locked + balance.free);
1090    }
1091
1092    #[rstest]
1093    fn test_parse_margin_message_with_foreign_requirements() {
1094        // Test case where trading USDT-settled contracts with XBT margin
1095        let margin_msg = BitmexMarginMsg {
1096            account: 123456,
1097            currency: Ustr::from("XBt"),
1098            risk_limit: Some(1000000000),
1099            amount: Some(100000000), // 1 BTC
1100            prev_realised_pnl: None,
1101            gross_comm: None,
1102            gross_open_cost: None,
1103            gross_open_premium: None,
1104            gross_exec_cost: None,
1105            gross_mark_value: None,
1106            risk_value: None,
1107            init_margin: None,  // No direct margin in XBT
1108            maint_margin: None, // No direct margin in XBT
1109            target_excess_margin: None,
1110            realised_pnl: None,
1111            unrealised_pnl: None,
1112            wallet_balance: Some(100000000),
1113            margin_balance: Some(100000000),
1114            margin_leverage: None,
1115            margin_used_pcnt: None,
1116            excess_margin: None,
1117            available_margin: Some(95000000), // 0.95 BTC available
1118            withdrawable_margin: None,
1119            maker_fee_discount: None,
1120            taker_fee_discount: None,
1121            timestamp: chrono::Utc::now(),
1122            foreign_margin_balance: Some(100000000), // Foreign margin balance in satoshis
1123            foreign_requirement: Some(5000000),      // 0.05 BTC required for USDT positions
1124        };
1125
1126        let account_id = AccountId::new("BITMEX-123456");
1127        let ts_init = UnixNanos::from(1_000_000_000);
1128
1129        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1130            .expect("Failed to parse account state with foreign requirements");
1131
1132        // Check balance
1133        let balance = &account_state.balances[0];
1134        assert_eq!(balance.currency, Currency::from("XBT"));
1135        assert_eq!(balance.total.as_f64(), 1.0);
1136        assert_eq!(balance.free.as_f64(), 0.95);
1137        assert_eq!(balance.locked.as_f64(), 0.05);
1138
1139        // No margins tracked
1140        assert_eq!(account_state.margins.len(), 0);
1141    }
1142
1143    #[rstest]
1144    fn test_parse_margin_message_with_both_standard_and_foreign() {
1145        // Test case with both standard and foreign margin requirements
1146        let margin_msg = BitmexMarginMsg {
1147            account: 123456,
1148            currency: Ustr::from("XBt"),
1149            risk_limit: Some(1000000000),
1150            amount: Some(100000000), // 1 BTC
1151            prev_realised_pnl: None,
1152            gross_comm: None,
1153            gross_open_cost: None,
1154            gross_open_premium: None,
1155            gross_exec_cost: None,
1156            gross_mark_value: None,
1157            risk_value: None,
1158            init_margin: Some(2000000),  // 0.02 BTC for XBT positions
1159            maint_margin: Some(1000000), // 0.01 BTC for XBT positions
1160            target_excess_margin: None,
1161            realised_pnl: None,
1162            unrealised_pnl: None,
1163            wallet_balance: Some(100000000),
1164            margin_balance: Some(100000000),
1165            margin_leverage: None,
1166            margin_used_pcnt: None,
1167            excess_margin: None,
1168            available_margin: Some(93000000), // 0.93 BTC available
1169            withdrawable_margin: None,
1170            maker_fee_discount: None,
1171            taker_fee_discount: None,
1172            timestamp: chrono::Utc::now(),
1173            foreign_margin_balance: Some(100000000),
1174            foreign_requirement: Some(5000000), // 0.05 BTC for USDT positions
1175        };
1176
1177        let account_id = AccountId::new("BITMEX-123456");
1178        let ts_init = UnixNanos::from(1_000_000_000);
1179
1180        let account_state = parse_account_state(&margin_msg, account_id, ts_init)
1181            .expect("Failed to parse account state with both margins");
1182
1183        // Check balance
1184        let balance = &account_state.balances[0];
1185        assert_eq!(balance.currency, Currency::from("XBT"));
1186        assert_eq!(balance.total.as_f64(), 1.0);
1187        assert_eq!(balance.free.as_f64(), 0.93);
1188        assert_eq!(balance.locked.as_f64(), 0.07); // 0.02 + 0.05 = 0.07 total margin
1189
1190        assert_eq!(account_state.margins.len(), 1);
1191        let xbt_margin = &account_state.margins[0];
1192        assert_eq!(xbt_margin.initial.as_f64(), 0.02); // 2000000 satoshis
1193        assert_eq!(xbt_margin.maintenance.as_f64(), 0.01); // 1000000 satoshis
1194    }
1195}