Skip to main content

nautilus_bitmex/websocket/
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//! Parsers that convert BitMEX WebSocket payloads into Nautilus data structures.
17
18use std::{num::NonZero, str::FromStr};
19
20use ahash::AHashMap;
21use jiff::tz::Offset;
22use nautilus_core::{UnixNanos, uuid::UUID4};
23#[cfg(test)]
24use nautilus_model::types::Currency;
25use nautilus_model::{
26    data::{
27        Bar, BarSpecification, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate,
28        MarkPriceUpdate, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick, depth::DEPTH10_LEN,
29    },
30    enums::{
31        AccountType, AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType,
32        PriceType, RecordFlag, TimeInForce, TrailingOffsetType,
33    },
34    events::{
35        OrderAccepted, OrderCanceled, OrderExpired, OrderRejected, OrderTriggered, OrderUpdated,
36        account::state::AccountState,
37    },
38    identifiers::{
39        AccountId, ClientOrderId, InstrumentId, OrderListId, StrategyId, Symbol, TradeId, TraderId,
40        VenueOrderId,
41    },
42    instruments::{Instrument, InstrumentAny},
43    reports::{FillReport, OrderStatusReport, PositionStatusReport},
44    types::{AccountBalance, MarginBalance, Money, Price, Quantity},
45};
46use rust_decimal::Decimal;
47use ustr::Ustr;
48
49use super::{
50    enums::{BitmexAction, BitmexWsTopic},
51    messages::{
52        BitmexExecutionMsg, BitmexFundingMsg, BitmexInstrumentMsg, BitmexMarginMsg,
53        BitmexOrderBook10Msg, BitmexOrderBookMsg, BitmexOrderMsg, BitmexPositionMsg,
54        BitmexQuoteMsg, BitmexTradeBinMsg, BitmexTradeMsg, BitmexWalletMsg,
55    },
56};
57use crate::{
58    common::{
59        consts::BITMEX_VENUE,
60        enums::{
61            BitmexExecInstruction, BitmexExecType, BitmexOrderStatus, BitmexOrderType,
62            BitmexPegPriceType, BitmexSide,
63        },
64        parse::{
65            bitmex_account_id, bitmex_currency_divisor, clean_reason, derive_trade_id,
66            extract_trigger_type, map_bitmex_currency, normalize_trade_bin_prices,
67            normalize_trade_bin_volume, parse_account_balance, parse_contracts_quantity,
68            parse_fractional_quantity, parse_instrument_id, parse_liquidity_side,
69            parse_optional_datetime_to_unix_nanos, parse_position_side,
70            parse_signed_contracts_quantity,
71        },
72    },
73    http::parse::get_currency,
74    websocket::messages::BitmexOrderUpdateMsg,
75};
76
77const BAR_SPEC_1_MINUTE: BarSpecification = BarSpecification {
78    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
79    aggregation: BarAggregation::Minute,
80    price_type: PriceType::Last,
81};
82const BAR_SPEC_5_MINUTE: BarSpecification = BarSpecification {
83    step: NonZero::new(5).expect("5 is a valid non-zero usize"),
84    aggregation: BarAggregation::Minute,
85    price_type: PriceType::Last,
86};
87const BAR_SPEC_1_HOUR: BarSpecification = BarSpecification {
88    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
89    aggregation: BarAggregation::Hour,
90    price_type: PriceType::Last,
91};
92const BAR_SPEC_1_DAY: BarSpecification = BarSpecification {
93    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
94    aggregation: BarAggregation::Day,
95    price_type: PriceType::Last,
96};
97
98/// Check if a symbol is an index symbol (starts with '.').
99///
100/// Index symbols in BitMEX represent indices like `.BXBT` and have different
101/// behavior from regular instruments:
102/// - They only have a single price value (no bid/ask spread).
103/// - They don't have trades or quotes.
104/// - Their price is delivered via the `lastPrice` field.
105#[inline]
106#[must_use]
107pub fn is_index_symbol(symbol: &Ustr) -> bool {
108    symbol.starts_with('.')
109}
110
111/// Converts a batch of BitMEX order-book rows into Nautilus delta events.
112#[must_use]
113pub fn parse_book_msg_vec(
114    data: Vec<BitmexOrderBookMsg>,
115    action: BitmexAction,
116    instruments: &AHashMap<Ustr, InstrumentAny>,
117    ts_init: UnixNanos,
118) -> Vec<Data> {
119    let mut deltas = Vec::with_capacity(data.len());
120
121    for msg in data {
122        if let Some(instrument) = instruments.get(&msg.symbol) {
123            let instrument_id = instrument.id();
124            let price_precision = instrument.price_precision();
125            deltas.push(Data::BookDelta(parse_book_msg(
126                &msg,
127                &action,
128                instrument,
129                instrument_id,
130                price_precision,
131                ts_init,
132            )));
133        } else {
134            log::error!(
135                "Instrument cache miss: book delta dropped for symbol={}",
136                msg.symbol
137            );
138        }
139    }
140
141    // Set F_LAST on the last delta so data engine knows the batch is complete
142    if let Some(Data::BookDelta(last_delta)) = deltas.last_mut() {
143        *last_delta = OrderBookDelta::new(
144            last_delta.instrument_id,
145            last_delta.action,
146            last_delta.order,
147            last_delta.flags | RecordFlag::F_LAST as u8,
148            last_delta.sequence,
149            last_delta.ts_event,
150            last_delta.ts_init,
151        );
152    }
153
154    deltas
155}
156
157/// Converts BitMEX level-10 snapshots into Nautilus depth events.
158#[must_use]
159pub fn parse_book10_msg_vec(
160    data: Vec<BitmexOrderBook10Msg>,
161    instruments: &AHashMap<Ustr, InstrumentAny>,
162    ts_init: UnixNanos,
163) -> Vec<Data> {
164    let mut depths = Vec::with_capacity(data.len());
165
166    for msg in data {
167        if let Some(instrument) = instruments.get(&msg.symbol) {
168            let instrument_id = instrument.id();
169            let price_precision = instrument.price_precision();
170            match parse_book10_msg(&msg, instrument, instrument_id, price_precision, ts_init) {
171                Ok(depth) => depths.push(Data::BookDepth(Box::new(depth))),
172                Err(e) => {
173                    log::error!("Failed to parse orderBook10 for symbol={}: {e}", msg.symbol);
174                }
175            }
176        } else {
177            log::error!(
178                "Instrument cache miss: depth message dropped for symbol={}",
179                msg.symbol
180            );
181        }
182    }
183    depths
184}
185
186/// Converts BitMEX trade messages into Nautilus trade data events.
187#[must_use]
188pub fn parse_trade_msg_vec(
189    data: Vec<BitmexTradeMsg>,
190    instruments: &AHashMap<Ustr, InstrumentAny>,
191    ts_init: UnixNanos,
192) -> Vec<Data> {
193    let mut trades = Vec::with_capacity(data.len());
194
195    for msg in data {
196        if let Some(instrument) = instruments.get(&msg.symbol) {
197            let instrument_id = instrument.id();
198            let price_precision = instrument.price_precision();
199            trades.push(Data::Trade(parse_trade_msg(
200                &msg,
201                instrument,
202                instrument_id,
203                price_precision,
204                ts_init,
205            )));
206        } else {
207            log::error!(
208                "Instrument cache miss: trade message dropped for symbol={}",
209                msg.symbol
210            );
211        }
212    }
213    trades
214}
215
216/// Converts aggregated trade-bin messages into Nautilus data events.
217#[must_use]
218pub fn parse_trade_bin_msg_vec(
219    data: Vec<BitmexTradeBinMsg>,
220    topic: &BitmexWsTopic,
221    instruments: &AHashMap<Ustr, InstrumentAny>,
222    ts_init: UnixNanos,
223) -> Vec<Data> {
224    let mut trades = Vec::with_capacity(data.len());
225
226    for msg in data {
227        if let Some(instrument) = instruments.get(&msg.symbol) {
228            let instrument_id = instrument.id();
229            let price_precision = instrument.price_precision();
230            trades.push(Data::Bar(parse_trade_bin_msg(
231                &msg,
232                topic,
233                instrument,
234                instrument_id,
235                price_precision,
236                ts_init,
237            )));
238        } else {
239            log::error!(
240                "Instrument cache miss: trade bin (bar) dropped for symbol={}",
241                msg.symbol
242            );
243        }
244    }
245    trades
246}
247
248/// Converts a BitMEX order book row into a Nautilus order-book delta.
249#[must_use]
250pub fn parse_book_msg(
251    msg: &BitmexOrderBookMsg,
252    action: &BitmexAction,
253    instrument: &InstrumentAny,
254    instrument_id: InstrumentId,
255    price_precision: u8,
256    ts_init: UnixNanos,
257) -> OrderBookDelta {
258    let flags = if action == &BitmexAction::Partial {
259        RecordFlag::F_SNAPSHOT as u8
260    } else {
261        0
262    };
263
264    let action = action.as_book_action();
265    let price = Price::new(msg.price, price_precision);
266    let side = msg.side.as_order_side();
267    let size = parse_contracts_quantity(msg.size.unwrap_or(0), instrument);
268    let order_id = msg.id;
269    let order = BookOrder::new(side, price, size, order_id);
270    let sequence = 0; // Not available
271    let ts_event = UnixNanos::from(msg.timestamp);
272
273    OrderBookDelta::new(
274        instrument_id,
275        action,
276        order,
277        flags,
278        sequence,
279        ts_event,
280        ts_init,
281    )
282}
283
284/// Parses an `OrderBook10` message into an `OrderBookDepth` object.
285///
286/// # Errors
287///
288/// Returns an error if the bid or ask arrays are not exactly 10 elements.
289pub fn parse_book10_msg(
290    msg: &BitmexOrderBook10Msg,
291    instrument: &InstrumentAny,
292    instrument_id: InstrumentId,
293    price_precision: u8,
294    ts_init: UnixNanos,
295) -> anyhow::Result<OrderBookDepth> {
296    let mut bids = Vec::with_capacity(DEPTH10_LEN);
297    let mut asks = Vec::with_capacity(DEPTH10_LEN);
298
299    // Initialized with zeros
300    let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
301    let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
302
303    for (i, level) in msg.bids.iter().enumerate() {
304        let bid_order = BookOrder::new(
305            OrderSide::Buy,
306            Price::new(level[0], price_precision),
307            parse_fractional_quantity(level[1], instrument),
308            0,
309        );
310
311        bids.push(bid_order);
312        bid_counts[i] = 1;
313    }
314
315    for (i, level) in msg.asks.iter().enumerate() {
316        let ask_order = BookOrder::new(
317            OrderSide::Sell,
318            Price::new(level[0], price_precision),
319            parse_fractional_quantity(level[1], instrument),
320            0,
321        );
322
323        asks.push(ask_order);
324        ask_counts[i] = 1;
325    }
326
327    let bids: [BookOrder; DEPTH10_LEN] = bids.try_into().map_err(|v: Vec<BookOrder>| {
328        anyhow::anyhow!(
329            "Bids length mismatch: expected {DEPTH10_LEN}, was {}",
330            v.len()
331        )
332    })?;
333    let asks: [BookOrder; DEPTH10_LEN] = asks.try_into().map_err(|v: Vec<BookOrder>| {
334        anyhow::anyhow!(
335            "Asks length mismatch: expected {DEPTH10_LEN}, was {}",
336            v.len()
337        )
338    })?;
339
340    let ts_event = UnixNanos::from(msg.timestamp);
341
342    Ok(OrderBookDepth::new(
343        instrument_id,
344        bids,
345        asks,
346        bid_counts,
347        ask_counts,
348        RecordFlag::F_SNAPSHOT as u8,
349        0, // Not applicable for BitMEX L2 books
350        ts_event,
351        ts_init,
352    ))
353}
354
355/// Converts a BitMEX quote message into a `QuoteTick`, filling missing data from cache.
356#[must_use]
357pub fn parse_quote_msg(
358    msg: &BitmexQuoteMsg,
359    last_quote: &QuoteTick,
360    instrument: &InstrumentAny,
361    instrument_id: InstrumentId,
362    price_precision: u8,
363    ts_init: UnixNanos,
364) -> QuoteTick {
365    let bid_price = match msg.bid_price {
366        Some(price) => Price::new(price, price_precision),
367        None => last_quote.bid_price,
368    };
369
370    let ask_price = match msg.ask_price {
371        Some(price) => Price::new(price, price_precision),
372        None => last_quote.ask_price,
373    };
374
375    let bid_size = match msg.bid_size {
376        Some(size) => parse_contracts_quantity(size, instrument),
377        None => last_quote.bid_size,
378    };
379
380    let ask_size = match msg.ask_size {
381        Some(size) => parse_contracts_quantity(size, instrument),
382        None => last_quote.ask_size,
383    };
384
385    let ts_event = UnixNanos::from(msg.timestamp);
386
387    QuoteTick::new(
388        instrument_id,
389        bid_price,
390        ask_price,
391        bid_size,
392        ask_size,
393        ts_event,
394        ts_init,
395    )
396}
397
398/// Converts a BitMEX trade message into a `TradeTick`.
399#[must_use]
400pub fn parse_trade_msg(
401    msg: &BitmexTradeMsg,
402    instrument: &InstrumentAny,
403    instrument_id: InstrumentId,
404    price_precision: u8,
405    ts_init: UnixNanos,
406) -> TradeTick {
407    let price = Price::new(msg.price, price_precision);
408    let size = parse_contracts_quantity(msg.size, instrument);
409    let aggressor_side = msg.side.as_aggressor_side();
410    let ts_event = UnixNanos::from(msg.timestamp);
411    let trade_id = match msg.trd_match_id {
412        Some(uuid) => TradeId::new(uuid.to_string()),
413        None => derive_trade_id(
414            msg.symbol,
415            ts_event.as_u64(),
416            msg.price,
417            msg.size as i64,
418            Some(msg.side.into()),
419        ),
420    };
421
422    TradeTick::new(
423        instrument_id,
424        price,
425        size,
426        aggressor_side,
427        trade_id,
428        ts_event,
429        ts_init,
430    )
431}
432
433/// Converts a BitMEX trade-bin summary into a `Bar` for the matching topic.
434#[must_use]
435pub fn parse_trade_bin_msg(
436    msg: &BitmexTradeBinMsg,
437    topic: &BitmexWsTopic,
438    instrument: &InstrumentAny,
439    instrument_id: InstrumentId,
440    price_precision: u8,
441    ts_init: UnixNanos,
442) -> Bar {
443    let spec = bar_spec_from_topic(topic);
444    let bar_type = BarType::new(instrument_id, spec, AggregationSource::External);
445
446    let open = Price::new(msg.open, price_precision);
447    let high = Price::new(msg.high, price_precision);
448    let low = Price::new(msg.low, price_precision);
449    let close = Price::new(msg.close, price_precision);
450
451    let (open, high, low, close) =
452        normalize_trade_bin_prices(open, high, low, close, &msg.symbol, Some(&bar_type));
453
454    let volume_contracts = normalize_trade_bin_volume(Some(msg.volume), &msg.symbol);
455    let volume = parse_contracts_quantity(volume_contracts, instrument);
456    let ts_event = UnixNanos::from(msg.timestamp);
457
458    Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init)
459}
460
461/// Converts a WebSocket topic to a bar specification.
462///
463/// Returns `BAR_SPEC_1_MINUTE` and logs an error for unsupported topics.
464#[must_use]
465pub fn bar_spec_from_topic(topic: &BitmexWsTopic) -> BarSpecification {
466    match topic {
467        BitmexWsTopic::TradeBin1m => BAR_SPEC_1_MINUTE,
468        BitmexWsTopic::TradeBin5m => BAR_SPEC_5_MINUTE,
469        BitmexWsTopic::TradeBin1h => BAR_SPEC_1_HOUR,
470        BitmexWsTopic::TradeBin1d => BAR_SPEC_1_DAY,
471        _ => {
472            log::error!("Bar specification not supported: topic={topic:?}");
473            BAR_SPEC_1_MINUTE
474        }
475    }
476}
477
478/// Converts a bar specification to a WebSocket topic.
479///
480/// Returns `TradeBin1m` and logs an error for unsupported specifications.
481#[must_use]
482pub fn topic_from_bar_spec(spec: BarSpecification) -> BitmexWsTopic {
483    match spec {
484        BAR_SPEC_1_MINUTE => BitmexWsTopic::TradeBin1m,
485        BAR_SPEC_5_MINUTE => BitmexWsTopic::TradeBin5m,
486        BAR_SPEC_1_HOUR => BitmexWsTopic::TradeBin1h,
487        BAR_SPEC_1_DAY => BitmexWsTopic::TradeBin1d,
488        _ => {
489            log::error!("Bar specification not supported: spec={spec:?}");
490            BitmexWsTopic::TradeBin1m
491        }
492    }
493}
494
495fn infer_order_type_from_msg(msg: &BitmexOrderMsg) -> OrderType {
496    if msg.stop_px.is_some() {
497        if msg.price.is_some() {
498            OrderType::StopLimit
499        } else {
500            OrderType::StopMarket
501        }
502    } else if msg.price.is_some() {
503        OrderType::Limit
504    } else {
505        OrderType::Market
506    }
507}
508
509/// Parse a BitMEX WebSocket order message into a Nautilus `OrderStatusReport`.
510///
511/// # References
512///
513/// <https://www.bitmex.com/app/wsAPI#Order>
514///
515/// # Errors
516///
517/// Returns an error if the time in force conversion fails.
518pub fn parse_order_msg(
519    msg: &BitmexOrderMsg,
520    instrument: &InstrumentAny,
521    order_type_cache: &mut AHashMap<ClientOrderId, OrderType>,
522    ts_init: UnixNanos,
523) -> anyhow::Result<OrderStatusReport> {
524    let account_id = bitmex_account_id(msg.account);
525    let instrument_id = parse_instrument_id(msg.symbol);
526    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
527    let common_side: BitmexSide = msg.side.into();
528    let order_side = OrderSide::from(common_side);
529
530    let order_type: OrderType = if let Some(ord_type) = msg.ord_type {
531        // Pegged orders with TrailingStopPeg are trailing stop orders
532        if ord_type == BitmexOrderType::Pegged
533            && msg.peg_price_type == Some(BitmexPegPriceType::TrailingStopPeg)
534        {
535            if msg.price.is_some() {
536                OrderType::TrailingStopLimit
537            } else {
538                OrderType::TrailingStopMarket
539            }
540        } else {
541            ord_type.into()
542        }
543    } else if let Some(client_order_id) = msg.cl_ord_id {
544        let client_order_id = ClientOrderId::new(client_order_id);
545        if let Some(&cached) = order_type_cache.get(&client_order_id) {
546            cached
547        } else {
548            let inferred = infer_order_type_from_msg(msg);
549            order_type_cache.insert(client_order_id, inferred);
550            inferred
551        }
552    } else {
553        infer_order_type_from_msg(msg)
554    };
555
556    let time_in_force: TimeInForce = match msg.time_in_force {
557        Some(tif) => tif.try_into().map_err(|e| anyhow::anyhow!("{e}"))?,
558        None => TimeInForce::Gtc,
559    };
560    let order_status: OrderStatus = msg.ord_status.into();
561    let quantity = parse_signed_contracts_quantity(msg.order_qty, instrument);
562    let filled_qty = parse_signed_contracts_quantity(msg.cum_qty, instrument);
563    let report_id = UUID4::new();
564    let ts_accepted =
565        parse_optional_datetime_to_unix_nanos(&Some(msg.transact_time), "transact_time");
566    let ts_last = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "timestamp");
567
568    let mut report = OrderStatusReport::new(
569        account_id,
570        instrument_id,
571        None, // client_order_id - will be set later if present
572        venue_order_id,
573        order_side.into(),
574        order_type,
575        time_in_force,
576        order_status,
577        quantity,
578        filled_qty,
579        ts_accepted,
580        ts_last,
581        ts_init,
582        Some(report_id),
583    );
584
585    if let Some(cl_ord_id) = &msg.cl_ord_id {
586        report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
587    }
588
589    if let Some(cl_ord_link_id) = &msg.cl_ord_link_id {
590        report = report.with_order_list_id(OrderListId::new(cl_ord_link_id));
591    }
592
593    if let Some(price) = msg.price {
594        report = report.with_price(Price::new(price, instrument.price_precision()));
595    }
596
597    if let Some(avg_px) = msg.avg_px {
598        report = report.with_avg_px(avg_px);
599    }
600
601    if let Some(trigger_price) = msg.stop_px {
602        report = report
603            .with_trigger_price(Price::new(trigger_price, instrument.price_precision()))
604            .with_trigger_type(extract_trigger_type(msg.exec_inst.as_ref()));
605    }
606
607    // Populate trailing offset for trailing stop orders
608    if matches!(
609        order_type,
610        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
611    ) && let Some(peg_offset) = msg.peg_offset_value
612    {
613        let trailing_offset = Decimal::try_from(peg_offset.abs())
614            .unwrap_or_else(|_| Decimal::new(peg_offset.abs() as i64, 0));
615        report = report
616            .with_trailing_offset(trailing_offset)
617            .with_trailing_offset_type(TrailingOffsetType::Price);
618
619        if msg.stop_px.is_none() {
620            report = report.with_trigger_type(extract_trigger_type(msg.exec_inst.as_ref()));
621        }
622    }
623
624    if let Some(exec_insts) = &msg.exec_inst {
625        for exec_inst in exec_insts {
626            match exec_inst {
627                BitmexExecInstruction::ParticipateDoNotInitiate => {
628                    report = report.with_post_only(true);
629                }
630                BitmexExecInstruction::ReduceOnly => {
631                    report = report.with_reduce_only(true);
632                }
633                _ => {}
634            }
635        }
636    }
637
638    // Extract rejection reason for rejected orders
639    if order_status == OrderStatus::Rejected {
640        if let Some(reason_str) = msg.ord_rej_reason.or(msg.text) {
641            log::debug!(
642                "Order rejected with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
643                venue_order_id,
644                msg.cl_ord_id,
645                reason_str,
646            );
647            report = report.with_cancel_reason(clean_reason(reason_str.as_ref()));
648        } else {
649            log::debug!(
650                "Order rejected without reason from BitMEX: order_id={:?}, client_order_id={:?}, ord_status={:?}, ord_rej_reason={:?}, text={:?}",
651                venue_order_id,
652                msg.cl_ord_id,
653                msg.ord_status,
654                msg.ord_rej_reason,
655                msg.text,
656            );
657        }
658    }
659
660    // Check if this is a canceled post-only order (BitMEX cancels instead of rejecting)
661    // We need to preserve the rejection reason for the execution client to handle
662    if order_status == OrderStatus::Canceled
663        && let Some(reason_str) = msg.ord_rej_reason.or(msg.text)
664    {
665        report = report.with_cancel_reason(clean_reason(reason_str.as_ref()));
666    }
667
668    Ok(report)
669}
670
671/// Parsed order event variants produced by [`parse_order_event`] for tracked orders.
672#[derive(Debug, Clone)]
673pub enum ParsedOrderEvent {
674    Accepted(OrderAccepted),
675    Canceled(OrderCanceled),
676    Expired(OrderExpired),
677    Triggered(OrderTriggered),
678    Rejected(OrderRejected),
679}
680
681/// Converts a full BitMEX order message into a [`ParsedOrderEvent`] for tracked orders.
682///
683/// Returns `None` for transitional statuses (`PendingNew`, `PendingCancel`, `PendingReplace`)
684/// and for fill-related statuses (`PartiallyFilled`, `Filled`, `Rejected`) that are handled
685/// through other channels (Execution table for fills, HTTP response for rejections).
686pub fn parse_order_event(
687    msg: &BitmexOrderMsg,
688    client_order_id: ClientOrderId,
689    account_id: AccountId,
690    trader_id: TraderId,
691    strategy_id: StrategyId,
692    ts_init: UnixNanos,
693) -> Option<ParsedOrderEvent> {
694    let instrument_id = parse_instrument_id(msg.symbol);
695    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
696    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "timestamp");
697
698    match msg.ord_status {
699        BitmexOrderStatus::New => {
700            let accepted = OrderAccepted::new(
701                trader_id,
702                strategy_id,
703                instrument_id,
704                client_order_id,
705                venue_order_id,
706                account_id,
707                UUID4::new(),
708                ts_event,
709                ts_init,
710                false,
711            );
712            Some(ParsedOrderEvent::Accepted(accepted))
713        }
714        BitmexOrderStatus::Canceled => {
715            // BitMEX cancels post-only orders instead of rejecting them when they
716            // would cross the spread. Detect via "ParticipateDoNotInitiate" reason.
717            let cancel_reason = msg
718                .ord_rej_reason
719                .or(msg.text)
720                .map(|r| clean_reason(r.as_ref()));
721
722            let is_post_only_rejection = cancel_reason
723                .as_deref()
724                .is_some_and(|r| r.contains("ParticipateDoNotInitiate"));
725
726            if is_post_only_rejection {
727                let rejected = OrderRejected::new(
728                    trader_id,
729                    strategy_id,
730                    instrument_id,
731                    client_order_id,
732                    account_id,
733                    Ustr::from(
734                        cancel_reason
735                            .as_deref()
736                            .unwrap_or("Post-only order rejected"),
737                    ),
738                    UUID4::new(),
739                    ts_event,
740                    ts_init,
741                    false,
742                    true, // due_post_only
743                );
744                Some(ParsedOrderEvent::Rejected(rejected))
745            } else {
746                let canceled = OrderCanceled::new(
747                    trader_id,
748                    strategy_id,
749                    instrument_id,
750                    client_order_id,
751                    UUID4::new(),
752                    ts_event,
753                    ts_init,
754                    false,
755                    Some(venue_order_id),
756                    Some(account_id),
757                    cancel_reason.as_deref().map(Ustr::from),
758                );
759                Some(ParsedOrderEvent::Canceled(canceled))
760            }
761        }
762        BitmexOrderStatus::Expired => {
763            let expired = OrderExpired::new(
764                trader_id,
765                strategy_id,
766                instrument_id,
767                client_order_id,
768                UUID4::new(),
769                ts_event,
770                ts_init,
771                false,
772                Some(venue_order_id),
773                Some(account_id),
774            );
775            Some(ParsedOrderEvent::Expired(expired))
776        }
777        // Rejections: handled at submit time via HTTP response
778        // Fills: handled via the Execution table, not order status updates
779        // Transitional: PendingNew, PendingCancel, PendingReplace
780        _ => None,
781    }
782}
783
784/// Parse a BitMEX WebSocket order update message into a Nautilus `OrderUpdated` event.
785///
786/// This handles partial updates where only changed fields are present.
787pub fn parse_order_update_msg(
788    msg: &BitmexOrderUpdateMsg,
789    instrument: &InstrumentAny,
790    account_id: AccountId,
791    ts_init: UnixNanos,
792) -> Option<OrderUpdated> {
793    // Uses external IDs; callers enrich with tracked identity when available
794    let trader_id = TraderId::external();
795    let strategy_id = StrategyId::external();
796    let instrument_id = parse_instrument_id(msg.symbol?);
797    let venue_order_id = Some(VenueOrderId::new(msg.order_id.to_string()));
798    let client_order_id = msg
799        .cl_ord_id
800        .as_ref()
801        .map_or_else(ClientOrderId::external, ClientOrderId::new);
802
803    // BitMEX partial updates may omit leaves_qty/cum_qty. When missing, we fall back
804    // to zero which signals the execution engine to use the cached order quantity.
805    let quantity = match (msg.leaves_qty, msg.cum_qty) {
806        (Some(leaves), Some(cum)) => parse_contracts_quantity((leaves + cum) as u64, instrument),
807        _ => Quantity::zero(instrument.size_precision()),
808    };
809    let price = msg
810        .price
811        .value()
812        .copied()
813        .map(|p| Price::new(p, instrument.price_precision()));
814
815    // BitMEX doesn't send trigger price in regular order updates?
816    let trigger_price = None;
817    // BitMEX doesn't send protection price in regular order updates
818    let protection_price = None;
819
820    let event_id = UUID4::new();
821    let ts_event = parse_optional_datetime_to_unix_nanos(&msg.timestamp, "timestamp");
822
823    Some(OrderUpdated::new(
824        trader_id,
825        strategy_id,
826        instrument_id,
827        client_order_id,
828        quantity,
829        event_id,
830        ts_event,
831        ts_init,
832        false, // reconciliation
833        venue_order_id,
834        Some(account_id),
835        price,
836        trigger_price,
837        protection_price,
838        false, // is_quote_quantity
839    ))
840}
841
842/// Parse a BitMEX WebSocket execution message into a Nautilus `FillReport`.
843///
844/// Handles different execution types appropriately:
845/// - `Trade`: Normal trade execution → FillReport
846/// - `Liquidation`: Auto-deleveraging or liquidation → FillReport
847/// - `Bankruptcy`: Bankruptcy execution → FillReport (with warning)
848/// - `Settlement`, `TrialFill`: Non-obvious cases → None (with warning)
849/// - `Funding`, `Insurance`, `Rebalance`: Expected non-fills → None (debug log)
850/// - Order state changes (`New`, `Canceled`, etc.): → None (debug log)
851///
852/// # References
853///
854/// <https://www.bitmex.com/app/wsAPI#Execution>
855pub fn parse_execution_msg(
856    msg: BitmexExecutionMsg,
857    instrument: &InstrumentAny,
858    ts_init: UnixNanos,
859) -> Option<FillReport> {
860    let exec_type = msg.exec_type?;
861
862    match exec_type {
863        // Position-affecting executions that generate fills
864        BitmexExecType::Trade | BitmexExecType::Liquidation => {}
865        BitmexExecType::Bankruptcy => {
866            log::warn!(
867                "Processing bankruptcy execution as fill: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
868                msg.order_id,
869                msg.symbol,
870            );
871        }
872
873        // Settlement executions are mark-to-market events, not fills
874        BitmexExecType::Settlement => {
875            log::debug!(
876                "Settlement execution skipped (not a fill): applies quanto conversion/PnL transfer on contract settlement: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
877                msg.order_id,
878                msg.symbol,
879            );
880            return None;
881        }
882        BitmexExecType::TrialFill => {
883            log::warn!(
884                "Trial fill execution received (testnet only), not processed as fill: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
885                msg.order_id,
886                msg.symbol,
887            );
888            return None;
889        }
890
891        // Expected non-fill executions
892        BitmexExecType::Funding => {
893            log::debug!(
894                "Funding execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
895                msg.order_id,
896                msg.symbol,
897            );
898            return None;
899        }
900        BitmexExecType::Insurance => {
901            log::debug!(
902                "Insurance execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
903                msg.order_id,
904                msg.symbol,
905            );
906            return None;
907        }
908        BitmexExecType::Rebalance => {
909            log::debug!(
910                "Rebalance execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
911                msg.order_id,
912                msg.symbol,
913            );
914            return None;
915        }
916
917        // Order state changes (not fills)
918        BitmexExecType::New
919        | BitmexExecType::Canceled
920        | BitmexExecType::CancelReject
921        | BitmexExecType::Replaced
922        | BitmexExecType::Rejected
923        | BitmexExecType::AmendReject
924        | BitmexExecType::Suspended
925        | BitmexExecType::Released
926        | BitmexExecType::TriggeredOrActivatedBySystem => {
927            log::debug!(
928                "Execution message skipped (order state change, not a fill): exec_type={exec_type:?}, order_id={:?}",
929                msg.order_id,
930            );
931            return None;
932        }
933
934        BitmexExecType::Unknown(ref type_str) => {
935            log::warn!(
936                "Unknown execution type received, skipping: exec_type={type_str}, order_id={:?}, symbol={:?}",
937                msg.order_id,
938                msg.symbol,
939            );
940            return None;
941        }
942    }
943
944    let account_id = bitmex_account_id(msg.account?);
945    let instrument_id = parse_instrument_id(msg.symbol?);
946    let venue_order_id = VenueOrderId::new(msg.order_id?.to_string());
947    let trade_id = TradeId::new(msg.trd_match_id?.to_string());
948    let side = msg.side?;
949    let order_side = OrderSide::from(BitmexSide::from(side));
950    let last_qty = parse_signed_contracts_quantity(msg.last_qty?, instrument);
951    let last_px = Price::new(msg.last_px?, instrument.price_precision());
952    let settlement_currency_str = msg.settl_currency.unwrap_or(Ustr::from("XBT"));
953    let mapped_currency = map_bitmex_currency(settlement_currency_str.as_str());
954    let currency = get_currency(&mapped_currency);
955    let commission = Money::new(msg.commission.unwrap_or(0.0), currency);
956    let liquidity_side = parse_liquidity_side(&msg.last_liquidity_ind);
957    let client_order_id = msg.cl_ord_id.map(ClientOrderId::new);
958    let venue_position_id = None; // Not applicable on BitMEX
959    let ts_event = parse_optional_datetime_to_unix_nanos(&msg.transact_time, "transact_time");
960
961    Some(FillReport::new(
962        account_id,
963        instrument_id,
964        venue_order_id,
965        trade_id,
966        order_side,
967        last_qty,
968        last_px,
969        commission,
970        liquidity_side,
971        client_order_id,
972        venue_position_id,
973        ts_event,
974        ts_init,
975        None,
976    ))
977}
978
979/// Parse a BitMEX WebSocket position message into a Nautilus `PositionStatusReport`.
980///
981/// # References
982///
983/// <https://www.bitmex.com/app/wsAPI#Position>
984#[must_use]
985pub fn parse_position_msg(
986    msg: &BitmexPositionMsg,
987    instrument: &InstrumentAny,
988    ts_init: UnixNanos,
989) -> PositionStatusReport {
990    let account_id = bitmex_account_id(msg.account);
991    let instrument_id = parse_instrument_id(msg.symbol);
992    let position_side = parse_position_side(msg.current_qty);
993    let quantity = parse_signed_contracts_quantity(msg.current_qty.unwrap_or(0), instrument);
994    let venue_position_id = None; // Not applicable on BitMEX
995    let avg_px_open = msg
996        .avg_entry_price
997        .and_then(|p| Decimal::from_str(&p.to_string()).ok());
998    let ts_last = parse_optional_datetime_to_unix_nanos(&msg.timestamp, "timestamp");
999
1000    PositionStatusReport::new(
1001        account_id,
1002        instrument_id,
1003        position_side,
1004        quantity,
1005        ts_last,
1006        ts_init,
1007        None,              // report_id
1008        venue_position_id, // venue_position_id
1009        avg_px_open,       // avg_px_open
1010    )
1011}
1012
1013/// Parse a BitMEX WebSocket instrument message for mark and index prices.
1014///
1015/// For index symbols (e.g., `.BXBT`):
1016/// - Uses the `lastPrice` field as the index price.
1017/// - Also emits the `markPrice` field (which equals `lastPrice` for indices).
1018///
1019/// For regular instruments:
1020/// - Uses the `index_price` field for index price updates.
1021/// - Uses the `mark_price` field for mark price updates.
1022///
1023/// Returns a Vec of Data containing mark and/or index price updates
1024/// or an empty Vec if no relevant price is present.
1025#[must_use]
1026pub fn parse_instrument_msg(
1027    msg: &BitmexInstrumentMsg,
1028    instruments_cache: &AHashMap<Ustr, InstrumentAny>,
1029    ts_init: UnixNanos,
1030) -> Vec<Data> {
1031    let mut updates = Vec::new();
1032    let is_index = is_index_symbol(&msg.symbol);
1033
1034    // Mark: `markPrice` (canonical, varies by `markMethod`) with `fairPrice` fallback.
1035    // Index: `indicativeSettlePrice` (BitMEX's actual field); `indexPrice` legacy fallback.
1036    let effective_mark_price = msg.mark_price.or(msg.fair_price);
1037    let effective_index_price = if is_index {
1038        msg.last_price
1039    } else {
1040        msg.indicative_settle_price.or(msg.index_price)
1041    };
1042
1043    if effective_mark_price.is_none() && effective_index_price.is_none() {
1044        return updates;
1045    }
1046
1047    let instrument_id = InstrumentId::new(Symbol::from_ustr_unchecked(msg.symbol), *BITMEX_VENUE);
1048    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "");
1049
1050    // Look up instrument for proper precision
1051    let price_precision = match instruments_cache.get(&msg.symbol) {
1052        Some(instrument) => instrument.price_precision(),
1053        None => {
1054            // BitMEX sends updates for all instruments on the instrument channel,
1055            // but we only cache instruments that are explicitly requested.
1056            // Index instruments (starting with '.') are not loaded via regular API endpoints.
1057            if is_index {
1058                log::trace!(
1059                    "Index instrument {} not in cache, skipping update",
1060                    msg.symbol
1061                );
1062            } else {
1063                log::debug!("Instrument {} not in cache, skipping update", msg.symbol);
1064            }
1065            return updates;
1066        }
1067    };
1068
1069    // Add mark price update if present
1070    // For index symbols, markPrice equals lastPrice and is valid to emit
1071    if let Some(mark_price) = effective_mark_price {
1072        let price = Price::new(mark_price, price_precision);
1073        updates.push(Data::MarkPrice(MarkPriceUpdate::new(
1074            instrument_id,
1075            price,
1076            ts_event,
1077            ts_init,
1078        )));
1079    }
1080
1081    // Add index price update if present
1082    if let Some(index_price) = effective_index_price {
1083        let price = Price::new(index_price, price_precision);
1084        updates.push(Data::IndexPrice(IndexPriceUpdate::new(
1085            instrument_id,
1086            price,
1087            ts_event,
1088            ts_init,
1089        )));
1090    }
1091
1092    updates
1093}
1094
1095/// Parse a BitMEX WebSocket funding message.
1096///
1097/// Returns `FundingRateUpdate` containing funding rate information.
1098/// Note: This returns `FundingRateUpdate` directly, not wrapped in Data enum,
1099/// to keep it separate from the FFI layer.
1100#[must_use]
1101pub fn parse_funding_msg(msg: &BitmexFundingMsg, ts_init: UnixNanos) -> FundingRateUpdate {
1102    let instrument_id = InstrumentId::from(format!("{}.BITMEX", msg.symbol));
1103    let funding_interval = Offset::UTC.to_datetime(msg.funding_interval);
1104    let interval_hours = u16::from(funding_interval.hour().cast_unsigned());
1105    let interval_minutes = u16::from(funding_interval.minute().cast_unsigned());
1106    let interval = Some(interval_hours * 60 + interval_minutes);
1107    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "");
1108
1109    FundingRateUpdate::new(
1110        instrument_id,
1111        msg.funding_rate,
1112        interval,
1113        None, // Next funding time not provided in this message
1114        ts_event,
1115        ts_init,
1116    )
1117}
1118
1119/// Parse a BitMEX wallet message into an AccountState.
1120///
1121/// BitMEX uses XBT (satoshis) as the base unit for Bitcoin.
1122/// 1 XBT = 0.00000001 BTC (1 satoshi).
1123///
1124/// # Panics
1125///
1126/// Panics if the balance calculation is invalid (total != locked + free).
1127#[must_use]
1128pub fn parse_wallet_msg(msg: &BitmexWalletMsg, ts_init: UnixNanos) -> AccountState {
1129    let account_id = bitmex_account_id(msg.account);
1130
1131    // Map BitMEX currency to standard currency code
1132    let currency_str = map_bitmex_currency(msg.currency.as_str());
1133    let currency = get_currency(&currency_str);
1134
1135    // Wallet messages do not expose locked margin; treat the full balance as free
1136    // and let the centralized constructor enforce `total == locked + free` at currency precision.
1137    let divisor = bitmex_currency_divisor(msg.currency.as_str());
1138    let amount_dec = Decimal::from(msg.amount.unwrap_or(0)) / divisor;
1139
1140    let balance = AccountBalance::from_total_and_locked(amount_dec, Decimal::ZERO, currency)
1141        .expect("Balance calculation should be valid");
1142
1143    AccountState::new(
1144        account_id,
1145        AccountType::Margin,
1146        vec![balance],
1147        vec![], // margins will be added separately
1148        true,   // is_reported
1149        UUID4::new(),
1150        ts_init,
1151        ts_init,
1152        None,
1153    )
1154}
1155
1156/// Parse a BitMEX margin message into an account-wide [`MarginBalance`].
1157#[must_use]
1158pub fn parse_margin_msg(msg: &BitmexMarginMsg) -> MarginBalance {
1159    let currency_str = map_bitmex_currency(msg.currency.as_str());
1160    let currency = get_currency(&currency_str);
1161
1162    let divisor = bitmex_currency_divisor(msg.currency.as_str());
1163    let initial_dec = Decimal::from(msg.init_margin.unwrap_or(0).max(0)) / divisor;
1164    let maintenance_dec = Decimal::from(msg.maint_margin.unwrap_or(0).max(0)) / divisor;
1165
1166    MarginBalance::new(
1167        Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
1168        Money::from_decimal(maintenance_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
1169        None,
1170    )
1171}
1172
1173/// Parses a BitMEX margin message into an [`AccountState`] with balances and margins.
1174#[must_use]
1175pub fn parse_margin_account_state(msg: &BitmexMarginMsg, ts_init: UnixNanos) -> AccountState {
1176    let account_id = bitmex_account_id(msg.account);
1177    let balance = parse_account_balance(msg);
1178
1179    let margin = parse_margin_msg(msg);
1180
1181    let margins = if !margin.initial.is_zero() || !margin.maintenance.is_zero() {
1182        vec![margin]
1183    } else {
1184        vec![]
1185    };
1186
1187    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "margin.timestamp");
1188
1189    AccountState::new(
1190        account_id,
1191        AccountType::Margin,
1192        vec![balance],
1193        margins,
1194        true,
1195        UUID4::new(),
1196        ts_event,
1197        ts_init,
1198        None,
1199    )
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204    use jiff::Timestamp;
1205    use nautilus_model::{
1206        enums::{AggressorSide, BookAction, LiquiditySide, PositionSide},
1207        identifiers::Symbol,
1208        instruments::crypto_perpetual::CryptoPerpetual,
1209    };
1210    use rstest::rstest;
1211    use ustr::Ustr;
1212
1213    use super::*;
1214    use crate::common::{
1215        enums::{BitmexExecType, BitmexOrderStatus},
1216        testing::load_test_json,
1217    };
1218
1219    fn create_test_perpetual_instrument_with_precisions(
1220        price_precision: u8,
1221        size_precision: u8,
1222    ) -> InstrumentAny {
1223        InstrumentAny::CryptoPerpetual(
1224            CryptoPerpetual::builder()
1225                .instrument_id(InstrumentId::from("XBTUSD.BITMEX"))
1226                .raw_symbol(Symbol::new("XBTUSD"))
1227                .base_currency(Currency::BTC())
1228                .quote_currency(Currency::USD())
1229                .settlement_currency(Currency::BTC())
1230                .is_inverse(true)
1231                .price_precision(price_precision)
1232                .size_precision(size_precision)
1233                .price_increment(Price::new(0.5, price_precision))
1234                .size_increment(Quantity::new(1.0, size_precision))
1235                .ts_event(UnixNanos::default())
1236                .ts_init(UnixNanos::default())
1237                .build()
1238                .unwrap(),
1239        )
1240    }
1241
1242    fn create_test_perpetual_instrument() -> InstrumentAny {
1243        create_test_perpetual_instrument_with_precisions(1, 0)
1244    }
1245
1246    #[rstest]
1247    fn test_orderbook_l2_message() {
1248        let json_data = load_test_json("ws_orderbook_l2.json");
1249
1250        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1251        let msg: BitmexOrderBookMsg = serde_json::from_str(&json_data).unwrap();
1252
1253        // Test Insert action
1254        let instrument = create_test_perpetual_instrument();
1255
1256        // Test Insert action (no snapshot flag)
1257        let delta = parse_book_msg(
1258            &msg,
1259            &BitmexAction::Insert,
1260            &instrument,
1261            instrument.id(),
1262            instrument.price_precision(),
1263            UnixNanos::from(3),
1264        );
1265        assert_eq!(delta.instrument_id, instrument_id);
1266        assert_eq!(delta.order.price, Price::from("98459.9"));
1267        assert_eq!(delta.order.size, Quantity::from(33000));
1268        assert_eq!(delta.order.side, OrderSide::Sell.into());
1269        assert_eq!(delta.order.order_id, 62400580205);
1270        assert_eq!(delta.action, BookAction::Add);
1271        assert_eq!(delta.flags, 0);
1272        assert_eq!(delta.sequence, 0);
1273        assert_eq!(delta.ts_event, 1732436782356000000); // 2024-11-24T08:26:22.356Z in nanos
1274        assert_eq!(delta.ts_init, 3);
1275
1276        // Test Partial action (should have F_SNAPSHOT flag)
1277        let delta = parse_book_msg(
1278            &msg,
1279            &BitmexAction::Partial,
1280            &instrument,
1281            instrument.id(),
1282            instrument.price_precision(),
1283            UnixNanos::from(3),
1284        );
1285        assert_eq!(delta.flags, RecordFlag::F_SNAPSHOT as u8);
1286        assert_eq!(delta.action, BookAction::Add);
1287
1288        // Test Update action (no flags)
1289        let delta = parse_book_msg(
1290            &msg,
1291            &BitmexAction::Update,
1292            &instrument,
1293            instrument.id(),
1294            instrument.price_precision(),
1295            UnixNanos::from(3),
1296        );
1297        assert_eq!(delta.flags, 0);
1298        assert_eq!(delta.action, BookAction::Update);
1299    }
1300
1301    #[rstest]
1302    fn test_orderbook10_message() {
1303        let json_data = load_test_json("ws_orderbook_10.json");
1304        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1305        let msg: BitmexOrderBook10Msg = serde_json::from_str(&json_data).unwrap();
1306        let instrument = create_test_perpetual_instrument();
1307        let depth = parse_book10_msg(
1308            &msg,
1309            &instrument,
1310            instrument.id(),
1311            instrument.price_precision(),
1312            UnixNanos::from(3),
1313        )
1314        .unwrap();
1315
1316        assert_eq!(depth.instrument_id, instrument_id);
1317
1318        // Check first bid level
1319        assert_eq!(depth.bids[0].price, Price::from("98490.3"));
1320        assert_eq!(depth.bids[0].size, Quantity::from(22400));
1321        assert_eq!(depth.bids[0].side, OrderSide::Buy.into());
1322
1323        // Check first ask level
1324        assert_eq!(depth.asks[0].price, Price::from("98490.4"));
1325        assert_eq!(depth.asks[0].size, Quantity::from(17600));
1326        assert_eq!(depth.asks[0].side, OrderSide::Sell.into());
1327
1328        // Check counts (should be 1 for each populated level)
1329        assert_eq!(depth.bid_counts.as_slice(), &[1; DEPTH10_LEN]);
1330        assert_eq!(depth.ask_counts.as_slice(), &[1; DEPTH10_LEN]);
1331
1332        // Check flags and timestamps
1333        assert_eq!(depth.sequence, 0);
1334        assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1335        assert_eq!(depth.ts_event, 1732436353513000000); // 2024-11-24T08:19:13.513Z in nanos
1336        assert_eq!(depth.ts_init, 3);
1337    }
1338
1339    #[rstest]
1340    fn test_quote_message() {
1341        let json_data = load_test_json("ws_quote.json");
1342
1343        let instrument_id = InstrumentId::from("BCHUSDT.BITMEX");
1344        let last_quote = QuoteTick::new(
1345            instrument_id,
1346            Price::new(487.50, 2),
1347            Price::new(488.20, 2),
1348            Quantity::from(100_000),
1349            Quantity::from(100_000),
1350            UnixNanos::from(1),
1351            UnixNanos::from(2),
1352        );
1353        let msg: BitmexQuoteMsg = serde_json::from_str(&json_data).unwrap();
1354        let instrument = create_test_perpetual_instrument_with_precisions(2, 0);
1355        let quote = parse_quote_msg(
1356            &msg,
1357            &last_quote,
1358            &instrument,
1359            instrument_id,
1360            instrument.price_precision(),
1361            UnixNanos::from(3),
1362        );
1363
1364        assert_eq!(quote.instrument_id, instrument_id);
1365        assert_eq!(quote.bid_price, Price::from("487.55"));
1366        assert_eq!(quote.ask_price, Price::from("488.25"));
1367        assert_eq!(quote.bid_size, Quantity::from(103_000));
1368        assert_eq!(quote.ask_size, Quantity::from(50_000));
1369        assert_eq!(quote.ts_event, 1732315465085000000);
1370        assert_eq!(quote.ts_init, 3);
1371    }
1372
1373    #[rstest]
1374    fn test_trade_message() {
1375        let json_data = load_test_json("ws_trade.json");
1376
1377        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1378        let msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1379        let instrument = create_test_perpetual_instrument();
1380        let trade = parse_trade_msg(
1381            &msg,
1382            &instrument,
1383            instrument.id(),
1384            instrument.price_precision(),
1385            UnixNanos::from(3),
1386        );
1387
1388        assert_eq!(trade.instrument_id, instrument_id);
1389        assert_eq!(trade.price, Price::from("98570.9"));
1390        assert_eq!(trade.size, Quantity::from(100));
1391        assert_eq!(trade.aggressor_side, AggressorSide::Sell);
1392        assert_eq!(
1393            trade.trade_id.to_string(),
1394            "00000000-006d-1000-0000-000e8737d536"
1395        );
1396        assert_eq!(trade.ts_event, 1732436138704000000); // 2024-11-24T08:15:38.704Z in nanos
1397        assert_eq!(trade.ts_init, 3);
1398    }
1399
1400    #[rstest]
1401    fn test_trade_message_derives_trade_id_when_trd_match_id_missing() {
1402        let json_data = load_test_json("ws_trade.json");
1403        let mut msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1404        msg.trd_match_id = None;
1405        let instrument = create_test_perpetual_instrument();
1406
1407        let trade = parse_trade_msg(
1408            &msg,
1409            &instrument,
1410            instrument.id(),
1411            instrument.price_precision(),
1412            UnixNanos::from(3),
1413        );
1414
1415        let mut again_msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1416        again_msg.trd_match_id = None;
1417        let again = parse_trade_msg(
1418            &again_msg,
1419            &instrument,
1420            instrument.id(),
1421            instrument.price_precision(),
1422            UnixNanos::from(3),
1423        );
1424
1425        assert_eq!(trade.trade_id, again.trade_id, "derivation must be stable");
1426        assert_eq!(trade.trade_id.as_str().len(), 16);
1427
1428        let mut altered: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1429        altered.trd_match_id = None;
1430        altered.price += 1.0;
1431        let altered_trade = parse_trade_msg(
1432            &altered,
1433            &instrument,
1434            instrument.id(),
1435            instrument.price_precision(),
1436            UnixNanos::from(3),
1437        );
1438        assert_ne!(trade.trade_id, altered_trade.trade_id);
1439    }
1440
1441    #[rstest]
1442    fn test_trade_bin_message() {
1443        let json_data = load_test_json("ws_trade_bin_1m.json");
1444
1445        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1446        let topic = BitmexWsTopic::TradeBin1m;
1447
1448        let msg: BitmexTradeBinMsg = serde_json::from_str(&json_data).unwrap();
1449        let instrument = create_test_perpetual_instrument();
1450        let bar = parse_trade_bin_msg(
1451            &msg,
1452            &topic,
1453            &instrument,
1454            instrument.id(),
1455            instrument.price_precision(),
1456            UnixNanos::from(3),
1457        );
1458
1459        assert_eq!(bar.instrument_id(), instrument_id);
1460        assert_eq!(
1461            bar.bar_type.spec(),
1462            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1463        );
1464        assert_eq!(bar.open, Price::from("97550.0"));
1465        assert_eq!(bar.high, Price::from("97584.4"));
1466        assert_eq!(bar.low, Price::from("97550.0"));
1467        assert_eq!(bar.close, Price::from("97570.1"));
1468        assert_eq!(bar.volume, Quantity::from(84_000));
1469        assert_eq!(bar.ts_event, 1732392420000000000); // 2024-11-23T20:07:00.000Z in nanos
1470        assert_eq!(bar.ts_init, 3);
1471    }
1472
1473    #[rstest]
1474    fn test_trade_bin_message_extreme_adjustment() {
1475        let topic = BitmexWsTopic::TradeBin1m;
1476        let instrument = create_test_perpetual_instrument();
1477
1478        let msg = BitmexTradeBinMsg {
1479            timestamp: "2024-01-01T00:00:00Z".parse::<Timestamp>().unwrap(),
1480            symbol: Ustr::from("XBTUSD"),
1481            open: 50_000.0,
1482            high: 49_990.0,
1483            low: 50_010.0,
1484            close: 50_005.0,
1485            trades: 10,
1486            volume: 1_000,
1487            vwap: Some(0.0),
1488            last_size: Some(0),
1489            turnover: 0,
1490            home_notional: 0.0,
1491            foreign_notional: 0.0,
1492            pool: None,
1493        };
1494
1495        let bar = parse_trade_bin_msg(
1496            &msg,
1497            &topic,
1498            &instrument,
1499            instrument.id(),
1500            instrument.price_precision(),
1501            UnixNanos::from(3),
1502        );
1503
1504        assert_eq!(bar.high, Price::from("50010.0"));
1505        assert_eq!(bar.low, Price::from("49990.0"));
1506        assert_eq!(bar.open, Price::from("50000.0"));
1507        assert_eq!(bar.close, Price::from("50005.0"));
1508        assert_eq!(bar.volume, Quantity::from(1_000));
1509    }
1510
1511    #[rstest]
1512    fn test_parse_order_msg() {
1513        let json_data = load_test_json("ws_order.json");
1514        let mut msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1515        msg.avg_px = Some(Decimal::from_str("30000.500000000004").unwrap());
1516        let mut cache = AHashMap::new();
1517        let instrument = create_test_perpetual_instrument();
1518        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1519
1520        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1521        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1522        assert_eq!(
1523            report.venue_order_id.to_string(),
1524            "550e8400-e29b-41d4-a716-446655440001"
1525        );
1526        assert_eq!(
1527            report.client_order_id.unwrap().to_string(),
1528            "mm_bitmex_1a/oemUeQ4CAJZgP3fjHsA"
1529        );
1530        assert_eq!(report.order_side, OrderSide::Buy.into());
1531        assert_eq!(report.order_type, OrderType::Limit);
1532        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1533        assert_eq!(report.order_status, OrderStatus::Accepted);
1534        assert_eq!(report.quantity, Quantity::from(100));
1535        assert_eq!(report.filled_qty, Quantity::from(0));
1536        assert_eq!(report.price.unwrap(), Price::from("98000.0"));
1537        assert_eq!(
1538            report.avg_px,
1539            Some(Decimal::from_str("30000.500000000004").unwrap())
1540        );
1541        assert_eq!(report.ts_accepted, 1732530600000000000); // 2024-11-25T10:30:00.000Z
1542    }
1543
1544    #[rstest]
1545    fn test_parse_order_msg_infers_type_when_missing() {
1546        let json_data = load_test_json("ws_order.json");
1547        let mut msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1548        msg.ord_type = None;
1549        msg.cl_ord_id = None;
1550        msg.price = Some(98_000.0);
1551        msg.stop_px = None;
1552
1553        let mut cache = AHashMap::new();
1554        let instrument = create_test_perpetual_instrument();
1555
1556        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1557
1558        assert_eq!(report.order_type, OrderType::Limit);
1559    }
1560
1561    #[rstest]
1562    fn test_parse_order_msg_rejected_with_reason() {
1563        let mut msg: BitmexOrderMsg =
1564            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1565        msg.ord_status = BitmexOrderStatus::Rejected;
1566        msg.ord_rej_reason = Some(Ustr::from("Insufficient available balance"));
1567        msg.text = None;
1568        msg.cum_qty = 0;
1569
1570        let mut cache = AHashMap::new();
1571        let instrument = create_test_perpetual_instrument();
1572        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1573
1574        assert_eq!(report.order_status, OrderStatus::Rejected);
1575        assert_eq!(
1576            report.cancel_reason,
1577            Some("Insufficient available balance".to_string())
1578        );
1579    }
1580
1581    #[rstest]
1582    fn test_parse_order_msg_rejected_with_text_fallback() {
1583        let mut msg: BitmexOrderMsg =
1584            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1585        msg.ord_status = BitmexOrderStatus::Rejected;
1586        msg.ord_rej_reason = None;
1587        msg.text = Some(Ustr::from("Order would execute immediately"));
1588        msg.cum_qty = 0;
1589
1590        let mut cache = AHashMap::new();
1591        let instrument = create_test_perpetual_instrument();
1592        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1593
1594        assert_eq!(report.order_status, OrderStatus::Rejected);
1595        assert_eq!(
1596            report.cancel_reason,
1597            Some("Order would execute immediately".to_string())
1598        );
1599    }
1600
1601    #[rstest]
1602    fn test_parse_order_msg_rejected_without_reason() {
1603        let mut msg: BitmexOrderMsg =
1604            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1605        msg.ord_status = BitmexOrderStatus::Rejected;
1606        msg.ord_rej_reason = None;
1607        msg.text = None;
1608        msg.cum_qty = 0;
1609
1610        let mut cache = AHashMap::new();
1611        let instrument = create_test_perpetual_instrument();
1612        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1613
1614        assert_eq!(report.order_status, OrderStatus::Rejected);
1615        assert_eq!(report.cancel_reason, None);
1616    }
1617
1618    #[rstest]
1619    fn test_parse_execution_msg() {
1620        let json_data = load_test_json("ws_execution.json");
1621        let msg: BitmexExecutionMsg = serde_json::from_str(&json_data).unwrap();
1622        let instrument = create_test_perpetual_instrument();
1623        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1624
1625        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1626        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1627        assert_eq!(
1628            fill.venue_order_id.to_string(),
1629            "550e8400-e29b-41d4-a716-446655440002"
1630        );
1631        assert_eq!(
1632            fill.trade_id.to_string(),
1633            "00000000-006d-1000-0000-000e8737d540"
1634        );
1635        assert_eq!(
1636            fill.client_order_id.unwrap().to_string(),
1637            "mm_bitmex_2b/oemUeQ4CAJZgP3fjHsB"
1638        );
1639        assert_eq!(fill.order_side, OrderSide::Sell);
1640        assert_eq!(fill.last_qty, Quantity::from(100));
1641        assert_eq!(fill.last_px, Price::from("98950.0"));
1642        assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
1643        assert_eq!(fill.commission, Money::new(0.00075, Currency::from("XBT")));
1644        assert_eq!(fill.commission.currency.code.to_string(), "XBT");
1645        assert_eq!(fill.ts_event, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1646    }
1647
1648    #[rstest]
1649    fn test_parse_execution_msg_non_trade() {
1650        // Test that non-trade executions return None
1651        let mut msg: BitmexExecutionMsg =
1652            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1653        msg.exec_type = Some(BitmexExecType::Settlement);
1654
1655        let instrument = create_test_perpetual_instrument();
1656        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1657        assert!(result.is_none());
1658    }
1659
1660    #[rstest]
1661    fn test_parse_cancel_reject_execution() {
1662        // Test that CancelReject messages can be parsed (even without symbol)
1663        let json = load_test_json("ws_execution_cancel_reject.json");
1664
1665        let msg: BitmexExecutionMsg = serde_json::from_str(&json).unwrap();
1666        assert_eq!(msg.exec_type, Some(BitmexExecType::CancelReject));
1667        assert_eq!(msg.ord_status, Some(BitmexOrderStatus::Rejected));
1668        assert_eq!(msg.symbol, None);
1669
1670        // Should return None since it's not a Trade
1671        let instrument = create_test_perpetual_instrument();
1672        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1673        assert!(result.is_none());
1674    }
1675
1676    #[rstest]
1677    fn test_parse_execution_msg_liquidation() {
1678        // Critical for ADL/hedge tracking
1679        let mut msg: BitmexExecutionMsg =
1680            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1681        msg.exec_type = Some(BitmexExecType::Liquidation);
1682
1683        let instrument = create_test_perpetual_instrument();
1684        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1685
1686        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1687        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1688        assert_eq!(fill.order_side, OrderSide::Sell);
1689        assert_eq!(fill.last_qty, Quantity::from(100));
1690        assert_eq!(fill.last_px, Price::from("98950.0"));
1691    }
1692
1693    #[rstest]
1694    fn test_parse_execution_msg_bankruptcy() {
1695        let mut msg: BitmexExecutionMsg =
1696            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1697        msg.exec_type = Some(BitmexExecType::Bankruptcy);
1698
1699        let instrument = create_test_perpetual_instrument();
1700        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1701
1702        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1703        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1704        assert_eq!(fill.order_side, OrderSide::Sell);
1705        assert_eq!(fill.last_qty, Quantity::from(100));
1706    }
1707
1708    #[rstest]
1709    fn test_parse_execution_msg_settlement() {
1710        let mut msg: BitmexExecutionMsg =
1711            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1712        msg.exec_type = Some(BitmexExecType::Settlement);
1713
1714        let instrument = create_test_perpetual_instrument();
1715        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1716        assert!(result.is_none());
1717    }
1718
1719    #[rstest]
1720    fn test_parse_execution_msg_trial_fill() {
1721        let mut msg: BitmexExecutionMsg =
1722            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1723        msg.exec_type = Some(BitmexExecType::TrialFill);
1724
1725        let instrument = create_test_perpetual_instrument();
1726        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1727        assert!(result.is_none());
1728    }
1729
1730    #[rstest]
1731    fn test_parse_execution_msg_funding() {
1732        let mut msg: BitmexExecutionMsg =
1733            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1734        msg.exec_type = Some(BitmexExecType::Funding);
1735
1736        let instrument = create_test_perpetual_instrument();
1737        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1738        assert!(result.is_none());
1739    }
1740
1741    #[rstest]
1742    fn test_parse_execution_msg_insurance() {
1743        let mut msg: BitmexExecutionMsg =
1744            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1745        msg.exec_type = Some(BitmexExecType::Insurance);
1746
1747        let instrument = create_test_perpetual_instrument();
1748        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1749        assert!(result.is_none());
1750    }
1751
1752    #[rstest]
1753    fn test_parse_execution_msg_rebalance() {
1754        let mut msg: BitmexExecutionMsg =
1755            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1756        msg.exec_type = Some(BitmexExecType::Rebalance);
1757
1758        let instrument = create_test_perpetual_instrument();
1759        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1760        assert!(result.is_none());
1761    }
1762
1763    #[rstest]
1764    fn test_parse_execution_msg_order_state_changes() {
1765        let instrument = create_test_perpetual_instrument();
1766
1767        let order_state_types = vec![
1768            BitmexExecType::New,
1769            BitmexExecType::Canceled,
1770            BitmexExecType::CancelReject,
1771            BitmexExecType::Replaced,
1772            BitmexExecType::Rejected,
1773            BitmexExecType::AmendReject,
1774            BitmexExecType::Suspended,
1775            BitmexExecType::Released,
1776            BitmexExecType::TriggeredOrActivatedBySystem,
1777        ];
1778
1779        for exec_type in order_state_types {
1780            let mut msg: BitmexExecutionMsg =
1781                serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1782            msg.exec_type = Some(exec_type.clone());
1783
1784            let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1785            assert!(
1786                result.is_none(),
1787                "Expected None for exec_type {exec_type:?}"
1788            );
1789        }
1790    }
1791
1792    #[rstest]
1793    fn test_parse_position_msg() {
1794        let json_data = load_test_json("ws_position.json");
1795        let msg: BitmexPositionMsg = serde_json::from_str(&json_data).unwrap();
1796        let instrument = create_test_perpetual_instrument();
1797        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1798
1799        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1800        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1801        assert_eq!(report.position_side, PositionSide::Long);
1802        assert_eq!(report.quantity, Quantity::from(1000));
1803        assert!(report.venue_position_id.is_none());
1804        assert_eq!(report.ts_last, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1805    }
1806
1807    #[rstest]
1808    fn test_parse_position_msg_short() {
1809        let mut msg: BitmexPositionMsg =
1810            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1811        msg.current_qty = Some(-500);
1812
1813        let instrument = create_test_perpetual_instrument();
1814        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1815        assert_eq!(report.position_side, PositionSide::Short);
1816        assert_eq!(report.quantity, Quantity::from(500));
1817    }
1818
1819    #[rstest]
1820    fn test_parse_position_msg_flat() {
1821        let mut msg: BitmexPositionMsg =
1822            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1823        msg.current_qty = Some(0);
1824
1825        let instrument = create_test_perpetual_instrument();
1826        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1827        assert_eq!(report.position_side, PositionSide::Flat);
1828        assert_eq!(report.quantity, Quantity::from(0));
1829    }
1830
1831    #[rstest]
1832    fn test_parse_wallet_msg() {
1833        let json_data = load_test_json("ws_wallet.json");
1834        let msg: BitmexWalletMsg = serde_json::from_str(&json_data).unwrap();
1835        let ts_init = UnixNanos::from(1);
1836        let account_state = parse_wallet_msg(&msg, ts_init);
1837
1838        assert_eq!(account_state.account_id.to_string(), "BITMEX-1234567");
1839        assert!(!account_state.balances.is_empty());
1840        let balance = &account_state.balances[0];
1841        assert_eq!(balance.currency.code.to_string(), "XBT");
1842        // Amount should be converted from satoshis (100005180 / 100_000_000.0 = 1.0000518)
1843        assert!((balance.total.as_f64() - 1.0000518).abs() < 1e-7);
1844        // Wallet messages do not carry locked margin; full amount is free.
1845        assert_eq!(balance.locked.as_f64(), 0.0);
1846        assert_eq!(balance.free.as_decimal(), balance.total.as_decimal());
1847    }
1848
1849    #[rstest]
1850    fn test_parse_wallet_msg_no_amount() {
1851        let mut msg: BitmexWalletMsg =
1852            serde_json::from_str(&load_test_json("ws_wallet.json")).unwrap();
1853        msg.amount = None;
1854
1855        let ts_init = UnixNanos::from(1);
1856        let account_state = parse_wallet_msg(&msg, ts_init);
1857        let balance = &account_state.balances[0];
1858        assert_eq!(balance.total.as_f64(), 0.0);
1859    }
1860
1861    #[rstest]
1862    fn test_parse_margin_msg() {
1863        let json_data = load_test_json("ws_margin.json");
1864        let msg: BitmexMarginMsg = serde_json::from_str(&json_data).unwrap();
1865        let margin_balance = parse_margin_msg(&msg);
1866
1867        assert_eq!(margin_balance.currency.code.to_string(), "XBT");
1868        assert!(margin_balance.instrument_id.is_none());
1869        // Values should be converted from satoshis to BTC
1870        // initMargin is 0 in test data, so should be 0.0
1871        assert_eq!(margin_balance.initial.as_f64(), 0.0);
1872        // maintMargin is 15949 satoshis = 0.00015949 BTC
1873        assert!((margin_balance.maintenance.as_f64() - 0.00015949).abs() < 1e-8);
1874    }
1875
1876    #[rstest]
1877    fn test_parse_margin_msg_no_available() {
1878        let mut msg: BitmexMarginMsg =
1879            serde_json::from_str(&load_test_json("ws_margin.json")).unwrap();
1880        msg.available_margin = None;
1881
1882        let margin_balance = parse_margin_msg(&msg);
1883        // Should still have valid margin values even if available_margin is None
1884        assert!(margin_balance.initial.as_f64() >= 0.0);
1885        assert!(margin_balance.maintenance.as_f64() >= 0.0);
1886    }
1887
1888    #[rstest]
1889    fn test_parse_margin_account_state_includes_margins() {
1890        let msg = BitmexMarginMsg {
1891            account: 123456,
1892            currency: Ustr::from("USDt"),
1893            risk_limit: None,
1894            amount: Some(5_000_000_000),
1895            prev_realised_pnl: None,
1896            gross_comm: None,
1897            gross_open_cost: None,
1898            gross_open_premium: None,
1899            gross_exec_cost: None,
1900            gross_mark_value: None,
1901            risk_value: None,
1902            init_margin: Some(200_000_000),  // 200 USDT
1903            maint_margin: Some(100_000_000), // 100 USDT
1904            target_excess_margin: None,
1905            realised_pnl: None,
1906            unrealised_pnl: None,
1907            wallet_balance: Some(5_000_000_000), // 5000 USDT
1908            margin_balance: None,
1909            margin_leverage: None,
1910            margin_used_pcnt: None,
1911            excess_margin: None,
1912            available_margin: Some(4_800_000_000), // 4800 USDT
1913            withdrawable_margin: None,
1914            maker_fee_discount: None,
1915            taker_fee_discount: None,
1916            timestamp: Timestamp::from_second(1_700_000_000).unwrap(),
1917            foreign_margin_balance: None,
1918            foreign_requirement: None,
1919        };
1920
1921        let ts_init = UnixNanos::from(1_000_000_000u64);
1922        let state = parse_margin_account_state(&msg, ts_init);
1923
1924        assert_eq!(state.account_id.to_string(), "BITMEX-123456");
1925        assert_eq!(state.account_type, AccountType::Margin);
1926        assert_eq!(state.balances.len(), 1);
1927        assert_eq!(state.margins.len(), 1);
1928
1929        let balance = &state.balances[0];
1930        assert_eq!(balance.total.as_f64(), 5000.0);
1931
1932        let margin = &state.margins[0];
1933        assert!(margin.instrument_id.is_none());
1934        assert_eq!(margin.currency.code, "USDT");
1935        assert_eq!(margin.initial.as_f64(), 200.0);
1936        assert_eq!(margin.maintenance.as_f64(), 100.0);
1937    }
1938
1939    #[rstest]
1940    fn test_parse_margin_account_state_zero_margins_excluded() {
1941        let msg = BitmexMarginMsg {
1942            account: 123456,
1943            currency: Ustr::from("XBt"),
1944            risk_limit: None,
1945            amount: Some(100_000_000),
1946            prev_realised_pnl: None,
1947            gross_comm: None,
1948            gross_open_cost: None,
1949            gross_open_premium: None,
1950            gross_exec_cost: None,
1951            gross_mark_value: None,
1952            risk_value: None,
1953            init_margin: Some(0),
1954            maint_margin: Some(0),
1955            target_excess_margin: None,
1956            realised_pnl: None,
1957            unrealised_pnl: None,
1958            wallet_balance: Some(100_000_000),
1959            margin_balance: None,
1960            margin_leverage: None,
1961            margin_used_pcnt: None,
1962            excess_margin: None,
1963            available_margin: Some(100_000_000),
1964            withdrawable_margin: None,
1965            maker_fee_discount: None,
1966            taker_fee_discount: None,
1967            timestamp: Timestamp::from_second(1_700_000_000).unwrap(),
1968            foreign_margin_balance: None,
1969            foreign_requirement: None,
1970        };
1971
1972        let state = parse_margin_account_state(&msg, UnixNanos::from(1_000_000_000u64));
1973
1974        assert_eq!(state.balances.len(), 1);
1975        assert_eq!(state.margins.len(), 0);
1976    }
1977
1978    #[rstest]
1979    fn test_parse_instrument_msg_both_prices() {
1980        let json_data = load_test_json("ws_instrument.json");
1981        let msg: BitmexInstrumentMsg = serde_json::from_str(&json_data).unwrap();
1982
1983        // Create cache with test instrument
1984        let mut instruments_cache = AHashMap::new();
1985        let test_instrument = create_test_perpetual_instrument();
1986        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
1987
1988        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
1989
1990        // Mark comes from `markPrice` (95125.7); index from `indicativeSettlePrice` (95126.0).
1991        assert_eq!(updates.len(), 2);
1992
1993        match &updates[0] {
1994            Data::MarkPrice(update) => {
1995                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
1996                assert_eq!(update.value.as_f64(), 95125.7);
1997            }
1998            _ => panic!("Expected MarkPriceUpdate at index 0"),
1999        }
2000
2001        match &updates[1] {
2002            Data::IndexPrice(update) => {
2003                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2004                assert_eq!(update.value.as_f64(), 95126.0);
2005            }
2006            _ => panic!("Expected IndexPriceUpdate at index 1"),
2007        }
2008    }
2009
2010    #[rstest]
2011    fn test_parse_instrument_msg_mark_price_only() {
2012        let mut msg: BitmexInstrumentMsg =
2013            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2014        msg.index_price = None;
2015        msg.indicative_settle_price = None;
2016
2017        let mut instruments_cache = AHashMap::new();
2018        let test_instrument = create_test_perpetual_instrument();
2019        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2020
2021        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2022
2023        assert_eq!(updates.len(), 1);
2024        match &updates[0] {
2025            Data::MarkPrice(update) => {
2026                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2027                assert_eq!(update.value.as_f64(), 95125.7);
2028            }
2029            _ => panic!("Expected MarkPriceUpdate"),
2030        }
2031    }
2032
2033    #[rstest]
2034    fn test_parse_instrument_msg_index_price_only() {
2035        let mut msg: BitmexInstrumentMsg =
2036            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2037        msg.mark_price = None;
2038        msg.fair_price = None;
2039
2040        let mut instruments_cache = AHashMap::new();
2041        let test_instrument = create_test_perpetual_instrument();
2042        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2043
2044        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2045
2046        assert_eq!(updates.len(), 1);
2047        match &updates[0] {
2048            Data::IndexPrice(update) => {
2049                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2050                assert_eq!(update.value.as_f64(), 95126.0);
2051            }
2052            _ => panic!("Expected IndexPriceUpdate"),
2053        }
2054    }
2055
2056    #[rstest]
2057    fn test_parse_instrument_msg_no_prices() {
2058        let mut msg: BitmexInstrumentMsg =
2059            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2060        msg.mark_price = None;
2061        msg.fair_price = None;
2062        msg.index_price = None;
2063        msg.indicative_settle_price = None;
2064        msg.last_price = None;
2065
2066        // Create cache with test instrument
2067        let mut instruments_cache = AHashMap::new();
2068        let test_instrument = create_test_perpetual_instrument();
2069        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2070
2071        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2072        assert_eq!(updates.len(), 0);
2073    }
2074
2075    #[rstest]
2076    fn test_parse_instrument_msg_index_symbol() {
2077        // Test for index symbols like .BXBT where lastPrice is the index price
2078        // and markPrice equals lastPrice
2079        let mut msg: BitmexInstrumentMsg =
2080            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2081        msg.symbol = Ustr::from(".BXBT");
2082        msg.last_price = Some(119163.05);
2083        msg.mark_price = Some(119163.05); // Index symbols have mark price equal to last price
2084        msg.fair_price = None;
2085        msg.index_price = None;
2086        msg.indicative_settle_price = None;
2087
2088        // Create instruments cache with proper precision for .BXBT
2089        let instrument_id = InstrumentId::from(".BXBT.BITMEX");
2090        let instrument = CryptoPerpetual::builder()
2091            .instrument_id(instrument_id)
2092            .raw_symbol(Symbol::from(".BXBT"))
2093            .base_currency(Currency::BTC())
2094            .quote_currency(Currency::USD())
2095            .settlement_currency(Currency::USD())
2096            .is_inverse(false)
2097            // price_precision (for 119163.05)
2098            .price_precision(2)
2099            .size_precision(8)
2100            .price_increment(Price::from("0.01"))
2101            .size_increment(Quantity::from("0.00000001"))
2102            .ts_event(UnixNanos::default())
2103            .ts_init(UnixNanos::default())
2104            .build()
2105            .unwrap();
2106        let mut instruments_cache = AHashMap::new();
2107        instruments_cache.insert(
2108            Ustr::from(".BXBT"),
2109            InstrumentAny::CryptoPerpetual(instrument),
2110        );
2111
2112        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2113
2114        assert_eq!(updates.len(), 2);
2115
2116        // Check mark price update
2117        match &updates[0] {
2118            Data::MarkPrice(update) => {
2119                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2120                assert_eq!(update.value, Price::from("119163.05"));
2121            }
2122            _ => panic!("Expected MarkPriceUpdate for index symbol"),
2123        }
2124
2125        // Check index price update
2126        match &updates[1] {
2127            Data::IndexPrice(update) => {
2128                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2129                assert_eq!(update.value, Price::from("119163.05"));
2130                assert_eq!(update.ts_init, UnixNanos::from(1));
2131            }
2132            _ => panic!("Expected IndexPriceUpdate for index symbol"),
2133        }
2134    }
2135
2136    /// Real-wire mark-only update: pins fairPrice preference.
2137    #[rstest]
2138    fn test_parse_instrument_msg_mark_update_wire_shape() {
2139        let msg: BitmexInstrumentMsg =
2140            serde_json::from_str(&load_test_json("ws_instrument_mark_update.json")).unwrap();
2141
2142        let instrument_id = InstrumentId::from("DOTUSDT.BITMEX");
2143        let instrument = CryptoPerpetual::builder()
2144            .instrument_id(instrument_id)
2145            .raw_symbol(Symbol::from("DOTUSDT"))
2146            .base_currency(Currency::from_str("DOT").unwrap())
2147            .quote_currency(Currency::USDT())
2148            .settlement_currency(Currency::USDT())
2149            .is_inverse(false)
2150            // price_precision (1.2669)
2151            .price_precision(4)
2152            .size_precision(8)
2153            .price_increment(Price::from("0.0001"))
2154            .size_increment(Quantity::from("0.00000001"))
2155            .ts_event(UnixNanos::default())
2156            .ts_init(UnixNanos::default())
2157            .build()
2158            .unwrap();
2159        let mut instruments_cache = AHashMap::new();
2160        instruments_cache.insert(
2161            Ustr::from("DOTUSDT"),
2162            InstrumentAny::CryptoPerpetual(instrument),
2163        );
2164
2165        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2166
2167        assert_eq!(updates.len(), 1);
2168        match &updates[0] {
2169            Data::MarkPrice(update) => {
2170                assert_eq!(update.instrument_id.to_string(), "DOTUSDT.BITMEX");
2171                assert_eq!(update.value, Price::from("1.2669"));
2172            }
2173            _ => panic!("Expected single MarkPriceUpdate for mark-update wire shape"),
2174        }
2175    }
2176
2177    /// Real-wire index-only update: regression for indicativeSettlePrice routing.
2178    #[rstest]
2179    fn test_parse_instrument_msg_index_update_wire_shape() {
2180        let msg: BitmexInstrumentMsg =
2181            serde_json::from_str(&load_test_json("ws_instrument_index_update.json")).unwrap();
2182
2183        let mut instruments_cache = AHashMap::new();
2184        instruments_cache.insert(
2185            Ustr::from("XBTUSD"),
2186            create_test_perpetual_instrument_with_precisions(2, 0),
2187        );
2188
2189        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2190
2191        assert_eq!(updates.len(), 1);
2192        match &updates[0] {
2193            Data::IndexPrice(update) => {
2194                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2195                assert_eq!(update.value, Price::from("75847.62"));
2196            }
2197            _ => panic!("Expected single IndexPriceUpdate for index-update wire shape"),
2198        }
2199    }
2200
2201    #[rstest]
2202    fn test_parse_funding_msg() {
2203        let json_data = load_test_json("ws_funding_rate.json");
2204        let msg: BitmexFundingMsg = serde_json::from_str(&json_data).unwrap();
2205        let update = parse_funding_msg(&msg, UnixNanos::from(1));
2206
2207        assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2208        assert_eq!(update.rate.to_string(), "0.0001");
2209        assert_eq!(update.interval, Some(60 * 8));
2210        assert!(update.next_funding_ns.is_none());
2211        assert_eq!(update.ts_event, UnixNanos::from(1732507200000000000));
2212        assert_eq!(update.ts_init, UnixNanos::from(1));
2213    }
2214}