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, OrderBookDepth10, QuoteTick, TradeTick,
29        depth::DEPTH10_LEN,
30    },
31    enums::{
32        AccountType, AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType,
33        PriceType, RecordFlag, TimeInForce, TrailingOffsetType,
34    },
35    events::{
36        OrderAccepted, OrderCanceled, OrderExpired, OrderRejected, OrderTriggered, OrderUpdated,
37        account::state::AccountState,
38    },
39    identifiers::{
40        AccountId, ClientOrderId, InstrumentId, OrderListId, StrategyId, Symbol, TradeId, TraderId,
41        VenueOrderId,
42    },
43    instruments::{Instrument, InstrumentAny},
44    reports::{FillReport, OrderStatusReport, PositionStatusReport},
45    types::{AccountBalance, MarginBalance, Money, Price, Quantity},
46};
47use rust_decimal::Decimal;
48use ustr::Ustr;
49
50use super::{
51    enums::{BitmexAction, BitmexWsTopic},
52    messages::{
53        BitmexExecutionMsg, BitmexFundingMsg, BitmexInstrumentMsg, BitmexMarginMsg,
54        BitmexOrderBook10Msg, BitmexOrderBookMsg, BitmexOrderMsg, BitmexPositionMsg,
55        BitmexQuoteMsg, BitmexTradeBinMsg, BitmexTradeMsg, BitmexWalletMsg,
56    },
57};
58use crate::{
59    common::{
60        consts::BITMEX_VENUE,
61        enums::{
62            BitmexExecInstruction, BitmexExecType, BitmexOrderStatus, BitmexOrderType,
63            BitmexPegPriceType, BitmexSide,
64        },
65        parse::{
66            bitmex_account_id, bitmex_currency_divisor, clean_reason, derive_trade_id,
67            extract_trigger_type, map_bitmex_currency, normalize_trade_bin_prices,
68            normalize_trade_bin_volume, parse_account_balance, parse_contracts_quantity,
69            parse_fractional_quantity, parse_instrument_id, parse_liquidity_side,
70            parse_optional_datetime_to_unix_nanos, parse_position_side,
71            parse_signed_contracts_quantity,
72        },
73    },
74    http::parse::get_currency,
75    websocket::messages::BitmexOrderUpdateMsg,
76};
77
78const BAR_SPEC_1_MINUTE: BarSpecification = BarSpecification {
79    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
80    aggregation: BarAggregation::Minute,
81    price_type: PriceType::Last,
82};
83const BAR_SPEC_5_MINUTE: BarSpecification = BarSpecification {
84    step: NonZero::new(5).expect("5 is a valid non-zero usize"),
85    aggregation: BarAggregation::Minute,
86    price_type: PriceType::Last,
87};
88const BAR_SPEC_1_HOUR: BarSpecification = BarSpecification {
89    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
90    aggregation: BarAggregation::Hour,
91    price_type: PriceType::Last,
92};
93const BAR_SPEC_1_DAY: BarSpecification = BarSpecification {
94    step: NonZero::new(1).expect("1 is a valid non-zero usize"),
95    aggregation: BarAggregation::Day,
96    price_type: PriceType::Last,
97};
98
99/// Check if a symbol is an index symbol (starts with '.').
100///
101/// Index symbols in BitMEX represent indices like `.BXBT` and have different
102/// behavior from regular instruments:
103/// - They only have a single price value (no bid/ask spread).
104/// - They don't have trades or quotes.
105/// - Their price is delivered via the `lastPrice` field.
106#[inline]
107#[must_use]
108pub fn is_index_symbol(symbol: &Ustr) -> bool {
109    symbol.starts_with('.')
110}
111
112/// Converts a batch of BitMEX order-book rows into Nautilus delta events.
113#[must_use]
114pub fn parse_book_msg_vec(
115    data: Vec<BitmexOrderBookMsg>,
116    action: BitmexAction,
117    instruments: &AHashMap<Ustr, InstrumentAny>,
118    ts_init: UnixNanos,
119) -> Vec<Data> {
120    let mut deltas = Vec::with_capacity(data.len());
121
122    for msg in data {
123        if let Some(instrument) = instruments.get(&msg.symbol) {
124            let instrument_id = instrument.id();
125            let price_precision = instrument.price_precision();
126            deltas.push(Data::Delta(parse_book_msg(
127                &msg,
128                &action,
129                instrument,
130                instrument_id,
131                price_precision,
132                ts_init,
133            )));
134        } else {
135            log::error!(
136                "Instrument cache miss: book delta dropped for symbol={}",
137                msg.symbol
138            );
139        }
140    }
141
142    // Set F_LAST on the last delta so data engine knows the batch is complete
143    if let Some(Data::Delta(last_delta)) = deltas.last_mut() {
144        *last_delta = OrderBookDelta::new(
145            last_delta.instrument_id,
146            last_delta.action,
147            last_delta.order,
148            last_delta.flags | RecordFlag::F_LAST as u8,
149            last_delta.sequence,
150            last_delta.ts_event,
151            last_delta.ts_init,
152        );
153    }
154
155    deltas
156}
157
158/// Converts BitMEX level-10 snapshots into Nautilus depth events.
159#[must_use]
160pub fn parse_book10_msg_vec(
161    data: Vec<BitmexOrderBook10Msg>,
162    instruments: &AHashMap<Ustr, InstrumentAny>,
163    ts_init: UnixNanos,
164) -> Vec<Data> {
165    let mut depths = Vec::with_capacity(data.len());
166
167    for msg in data {
168        if let Some(instrument) = instruments.get(&msg.symbol) {
169            let instrument_id = instrument.id();
170            let price_precision = instrument.price_precision();
171            match parse_book10_msg(&msg, instrument, instrument_id, price_precision, ts_init) {
172                Ok(depth) => depths.push(Data::Depth10(Box::new(depth))),
173                Err(e) => {
174                    log::error!("Failed to parse orderBook10 for symbol={}: {e}", msg.symbol);
175                }
176            }
177        } else {
178            log::error!(
179                "Instrument cache miss: depth10 message dropped for symbol={}",
180                msg.symbol
181            );
182        }
183    }
184    depths
185}
186
187/// Converts BitMEX trade messages into Nautilus trade data events.
188#[must_use]
189pub fn parse_trade_msg_vec(
190    data: Vec<BitmexTradeMsg>,
191    instruments: &AHashMap<Ustr, InstrumentAny>,
192    ts_init: UnixNanos,
193) -> Vec<Data> {
194    let mut trades = Vec::with_capacity(data.len());
195
196    for msg in data {
197        if let Some(instrument) = instruments.get(&msg.symbol) {
198            let instrument_id = instrument.id();
199            let price_precision = instrument.price_precision();
200            trades.push(Data::Trade(parse_trade_msg(
201                &msg,
202                instrument,
203                instrument_id,
204                price_precision,
205                ts_init,
206            )));
207        } else {
208            log::error!(
209                "Instrument cache miss: trade message dropped for symbol={}",
210                msg.symbol
211            );
212        }
213    }
214    trades
215}
216
217/// Converts aggregated trade-bin messages into Nautilus data events.
218#[must_use]
219pub fn parse_trade_bin_msg_vec(
220    data: Vec<BitmexTradeBinMsg>,
221    topic: &BitmexWsTopic,
222    instruments: &AHashMap<Ustr, InstrumentAny>,
223    ts_init: UnixNanos,
224) -> Vec<Data> {
225    let mut trades = Vec::with_capacity(data.len());
226
227    for msg in data {
228        if let Some(instrument) = instruments.get(&msg.symbol) {
229            let instrument_id = instrument.id();
230            let price_precision = instrument.price_precision();
231            trades.push(Data::Bar(parse_trade_bin_msg(
232                &msg,
233                topic,
234                instrument,
235                instrument_id,
236                price_precision,
237                ts_init,
238            )));
239        } else {
240            log::error!(
241                "Instrument cache miss: trade bin (bar) dropped for symbol={}",
242                msg.symbol
243            );
244        }
245    }
246    trades
247}
248
249/// Converts a BitMEX order book row into a Nautilus order-book delta.
250#[must_use]
251pub fn parse_book_msg(
252    msg: &BitmexOrderBookMsg,
253    action: &BitmexAction,
254    instrument: &InstrumentAny,
255    instrument_id: InstrumentId,
256    price_precision: u8,
257    ts_init: UnixNanos,
258) -> OrderBookDelta {
259    let flags = if action == &BitmexAction::Partial {
260        RecordFlag::F_SNAPSHOT as u8
261    } else {
262        0
263    };
264
265    let action = action.as_book_action();
266    let price = Price::new(msg.price, price_precision);
267    let side = msg.side.as_order_side();
268    let size = parse_contracts_quantity(msg.size.unwrap_or(0), instrument);
269    let order_id = msg.id;
270    let order = BookOrder::new(side, price, size, order_id);
271    let sequence = 0; // Not available
272    let ts_event = UnixNanos::from(msg.timestamp);
273
274    OrderBookDelta::new(
275        instrument_id,
276        action,
277        order,
278        flags,
279        sequence,
280        ts_event,
281        ts_init,
282    )
283}
284
285/// Parses an `OrderBook10` message into an `OrderBookDepth10` object.
286///
287/// # Errors
288///
289/// Returns an error if the bid or ask arrays are not exactly 10 elements.
290pub fn parse_book10_msg(
291    msg: &BitmexOrderBook10Msg,
292    instrument: &InstrumentAny,
293    instrument_id: InstrumentId,
294    price_precision: u8,
295    ts_init: UnixNanos,
296) -> anyhow::Result<OrderBookDepth10> {
297    let mut bids = Vec::with_capacity(DEPTH10_LEN);
298    let mut asks = Vec::with_capacity(DEPTH10_LEN);
299
300    // Initialized with zeros
301    let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
302    let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
303
304    for (i, level) in msg.bids.iter().enumerate() {
305        let bid_order = BookOrder::new(
306            OrderSide::Buy,
307            Price::new(level[0], price_precision),
308            parse_fractional_quantity(level[1], instrument),
309            0,
310        );
311
312        bids.push(bid_order);
313        bid_counts[i] = 1;
314    }
315
316    for (i, level) in msg.asks.iter().enumerate() {
317        let ask_order = BookOrder::new(
318            OrderSide::Sell,
319            Price::new(level[0], price_precision),
320            parse_fractional_quantity(level[1], instrument),
321            0,
322        );
323
324        asks.push(ask_order);
325        ask_counts[i] = 1;
326    }
327
328    let bids: [BookOrder; DEPTH10_LEN] = bids.try_into().map_err(|v: Vec<BookOrder>| {
329        anyhow::anyhow!(
330            "Bids length mismatch: expected {DEPTH10_LEN}, was {}",
331            v.len()
332        )
333    })?;
334    let asks: [BookOrder; DEPTH10_LEN] = asks.try_into().map_err(|v: Vec<BookOrder>| {
335        anyhow::anyhow!(
336            "Asks length mismatch: expected {DEPTH10_LEN}, was {}",
337            v.len()
338        )
339    })?;
340
341    let ts_event = UnixNanos::from(msg.timestamp);
342
343    Ok(OrderBookDepth10::new(
344        instrument_id,
345        bids,
346        asks,
347        bid_counts,
348        ask_counts,
349        RecordFlag::F_SNAPSHOT as u8,
350        0, // Not applicable for BitMEX L2 books
351        ts_event,
352        ts_init,
353    ))
354}
355
356/// Converts a BitMEX quote message into a `QuoteTick`, filling missing data from cache.
357#[must_use]
358pub fn parse_quote_msg(
359    msg: &BitmexQuoteMsg,
360    last_quote: &QuoteTick,
361    instrument: &InstrumentAny,
362    instrument_id: InstrumentId,
363    price_precision: u8,
364    ts_init: UnixNanos,
365) -> QuoteTick {
366    let bid_price = match msg.bid_price {
367        Some(price) => Price::new(price, price_precision),
368        None => last_quote.bid_price,
369    };
370
371    let ask_price = match msg.ask_price {
372        Some(price) => Price::new(price, price_precision),
373        None => last_quote.ask_price,
374    };
375
376    let bid_size = match msg.bid_size {
377        Some(size) => parse_contracts_quantity(size, instrument),
378        None => last_quote.bid_size,
379    };
380
381    let ask_size = match msg.ask_size {
382        Some(size) => parse_contracts_quantity(size, instrument),
383        None => last_quote.ask_size,
384    };
385
386    let ts_event = UnixNanos::from(msg.timestamp);
387
388    QuoteTick::new(
389        instrument_id,
390        bid_price,
391        ask_price,
392        bid_size,
393        ask_size,
394        ts_event,
395        ts_init,
396    )
397}
398
399/// Converts a BitMEX trade message into a `TradeTick`.
400#[must_use]
401pub fn parse_trade_msg(
402    msg: &BitmexTradeMsg,
403    instrument: &InstrumentAny,
404    instrument_id: InstrumentId,
405    price_precision: u8,
406    ts_init: UnixNanos,
407) -> TradeTick {
408    let price = Price::new(msg.price, price_precision);
409    let size = parse_contracts_quantity(msg.size, instrument);
410    let aggressor_side = msg.side.as_aggressor_side();
411    let ts_event = UnixNanos::from(msg.timestamp);
412    let trade_id = match msg.trd_match_id {
413        Some(uuid) => TradeId::new(uuid.to_string()),
414        None => derive_trade_id(
415            msg.symbol,
416            ts_event.as_u64(),
417            msg.price,
418            msg.size as i64,
419            Some(msg.side.into()),
420        ),
421    };
422
423    TradeTick::new(
424        instrument_id,
425        price,
426        size,
427        aggressor_side,
428        trade_id,
429        ts_event,
430        ts_init,
431    )
432}
433
434/// Converts a BitMEX trade-bin summary into a `Bar` for the matching topic.
435#[must_use]
436pub fn parse_trade_bin_msg(
437    msg: &BitmexTradeBinMsg,
438    topic: &BitmexWsTopic,
439    instrument: &InstrumentAny,
440    instrument_id: InstrumentId,
441    price_precision: u8,
442    ts_init: UnixNanos,
443) -> Bar {
444    let spec = bar_spec_from_topic(topic);
445    let bar_type = BarType::new(instrument_id, spec, AggregationSource::External);
446
447    let open = Price::new(msg.open, price_precision);
448    let high = Price::new(msg.high, price_precision);
449    let low = Price::new(msg.low, price_precision);
450    let close = Price::new(msg.close, price_precision);
451
452    let (open, high, low, close) =
453        normalize_trade_bin_prices(open, high, low, close, &msg.symbol, Some(&bar_type));
454
455    let volume_contracts = normalize_trade_bin_volume(Some(msg.volume), &msg.symbol);
456    let volume = parse_contracts_quantity(volume_contracts, instrument);
457    let ts_event = UnixNanos::from(msg.timestamp);
458
459    Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init)
460}
461
462/// Converts a WebSocket topic to a bar specification.
463///
464/// Returns `BAR_SPEC_1_MINUTE` and logs an error for unsupported topics.
465#[must_use]
466pub fn bar_spec_from_topic(topic: &BitmexWsTopic) -> BarSpecification {
467    match topic {
468        BitmexWsTopic::TradeBin1m => BAR_SPEC_1_MINUTE,
469        BitmexWsTopic::TradeBin5m => BAR_SPEC_5_MINUTE,
470        BitmexWsTopic::TradeBin1h => BAR_SPEC_1_HOUR,
471        BitmexWsTopic::TradeBin1d => BAR_SPEC_1_DAY,
472        _ => {
473            log::error!("Bar specification not supported: topic={topic:?}");
474            BAR_SPEC_1_MINUTE
475        }
476    }
477}
478
479/// Converts a bar specification to a WebSocket topic.
480///
481/// Returns `TradeBin1m` and logs an error for unsupported specifications.
482#[must_use]
483pub fn topic_from_bar_spec(spec: BarSpecification) -> BitmexWsTopic {
484    match spec {
485        BAR_SPEC_1_MINUTE => BitmexWsTopic::TradeBin1m,
486        BAR_SPEC_5_MINUTE => BitmexWsTopic::TradeBin5m,
487        BAR_SPEC_1_HOUR => BitmexWsTopic::TradeBin1h,
488        BAR_SPEC_1_DAY => BitmexWsTopic::TradeBin1d,
489        _ => {
490            log::error!("Bar specification not supported: spec={spec:?}");
491            BitmexWsTopic::TradeBin1m
492        }
493    }
494}
495
496fn infer_order_type_from_msg(msg: &BitmexOrderMsg) -> OrderType {
497    if msg.stop_px.is_some() {
498        if msg.price.is_some() {
499            OrderType::StopLimit
500        } else {
501            OrderType::StopMarket
502        }
503    } else if msg.price.is_some() {
504        OrderType::Limit
505    } else {
506        OrderType::Market
507    }
508}
509
510/// Parse a BitMEX WebSocket order message into a Nautilus `OrderStatusReport`.
511///
512/// # References
513///
514/// <https://www.bitmex.com/app/wsAPI#Order>
515///
516/// # Errors
517///
518/// Returns an error if the time in force conversion fails.
519pub fn parse_order_msg(
520    msg: &BitmexOrderMsg,
521    instrument: &InstrumentAny,
522    order_type_cache: &mut AHashMap<ClientOrderId, OrderType>,
523    ts_init: UnixNanos,
524) -> anyhow::Result<OrderStatusReport> {
525    let account_id = bitmex_account_id(msg.account);
526    let instrument_id = parse_instrument_id(msg.symbol);
527    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
528    let common_side: BitmexSide = msg.side.into();
529    let order_side = OrderSide::from(common_side);
530
531    let order_type: OrderType = if let Some(ord_type) = msg.ord_type {
532        // Pegged orders with TrailingStopPeg are trailing stop orders
533        if ord_type == BitmexOrderType::Pegged
534            && msg.peg_price_type == Some(BitmexPegPriceType::TrailingStopPeg)
535        {
536            if msg.price.is_some() {
537                OrderType::TrailingStopLimit
538            } else {
539                OrderType::TrailingStopMarket
540            }
541        } else {
542            ord_type.into()
543        }
544    } else if let Some(client_order_id) = msg.cl_ord_id {
545        let client_order_id = ClientOrderId::new(client_order_id);
546        if let Some(&cached) = order_type_cache.get(&client_order_id) {
547            cached
548        } else {
549            let inferred = infer_order_type_from_msg(msg);
550            order_type_cache.insert(client_order_id, inferred);
551            inferred
552        }
553    } else {
554        infer_order_type_from_msg(msg)
555    };
556
557    let time_in_force: TimeInForce = match msg.time_in_force {
558        Some(tif) => tif.try_into().map_err(|e| anyhow::anyhow!("{e}"))?,
559        None => TimeInForce::Gtc,
560    };
561    let order_status: OrderStatus = msg.ord_status.into();
562    let quantity = parse_signed_contracts_quantity(msg.order_qty, instrument);
563    let filled_qty = parse_signed_contracts_quantity(msg.cum_qty, instrument);
564    let report_id = UUID4::new();
565    let ts_accepted =
566        parse_optional_datetime_to_unix_nanos(&Some(msg.transact_time), "transact_time");
567    let ts_last = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "timestamp");
568
569    let mut report = OrderStatusReport::new(
570        account_id,
571        instrument_id,
572        None, // client_order_id - will be set later if present
573        venue_order_id,
574        order_side.into(),
575        order_type,
576        time_in_force,
577        order_status,
578        quantity,
579        filled_qty,
580        ts_accepted,
581        ts_last,
582        ts_init,
583        Some(report_id),
584    );
585
586    if let Some(cl_ord_id) = &msg.cl_ord_id {
587        report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
588    }
589
590    if let Some(cl_ord_link_id) = &msg.cl_ord_link_id {
591        report = report.with_order_list_id(OrderListId::new(cl_ord_link_id));
592    }
593
594    if let Some(price) = msg.price {
595        report = report.with_price(Price::new(price, instrument.price_precision()));
596    }
597
598    if let Some(avg_px) = msg.avg_px {
599        report = report.with_avg_px(avg_px);
600    }
601
602    if let Some(trigger_price) = msg.stop_px {
603        report = report
604            .with_trigger_price(Price::new(trigger_price, instrument.price_precision()))
605            .with_trigger_type(extract_trigger_type(msg.exec_inst.as_ref()));
606    }
607
608    // Populate trailing offset for trailing stop orders
609    if matches!(
610        order_type,
611        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
612    ) && let Some(peg_offset) = msg.peg_offset_value
613    {
614        let trailing_offset = Decimal::try_from(peg_offset.abs())
615            .unwrap_or_else(|_| Decimal::new(peg_offset.abs() as i64, 0));
616        report = report
617            .with_trailing_offset(trailing_offset)
618            .with_trailing_offset_type(TrailingOffsetType::Price);
619
620        if msg.stop_px.is_none() {
621            report = report.with_trigger_type(extract_trigger_type(msg.exec_inst.as_ref()));
622        }
623    }
624
625    if let Some(exec_insts) = &msg.exec_inst {
626        for exec_inst in exec_insts {
627            match exec_inst {
628                BitmexExecInstruction::ParticipateDoNotInitiate => {
629                    report = report.with_post_only(true);
630                }
631                BitmexExecInstruction::ReduceOnly => {
632                    report = report.with_reduce_only(true);
633                }
634                _ => {}
635            }
636        }
637    }
638
639    // Extract rejection reason for rejected orders
640    if order_status == OrderStatus::Rejected {
641        if let Some(reason_str) = msg.ord_rej_reason.or(msg.text) {
642            log::debug!(
643                "Order rejected with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
644                venue_order_id,
645                msg.cl_ord_id,
646                reason_str,
647            );
648            report = report.with_cancel_reason(clean_reason(reason_str.as_ref()));
649        } else {
650            log::debug!(
651                "Order rejected without reason from BitMEX: order_id={:?}, client_order_id={:?}, ord_status={:?}, ord_rej_reason={:?}, text={:?}",
652                venue_order_id,
653                msg.cl_ord_id,
654                msg.ord_status,
655                msg.ord_rej_reason,
656                msg.text,
657            );
658        }
659    }
660
661    // Check if this is a canceled post-only order (BitMEX cancels instead of rejecting)
662    // We need to preserve the rejection reason for the execution client to handle
663    if order_status == OrderStatus::Canceled
664        && let Some(reason_str) = msg.ord_rej_reason.or(msg.text)
665    {
666        report = report.with_cancel_reason(clean_reason(reason_str.as_ref()));
667    }
668
669    Ok(report)
670}
671
672/// Parsed order event variants produced by [`parse_order_event`] for tracked orders.
673#[derive(Debug, Clone)]
674pub enum ParsedOrderEvent {
675    Accepted(OrderAccepted),
676    Canceled(OrderCanceled),
677    Expired(OrderExpired),
678    Triggered(OrderTriggered),
679    Rejected(OrderRejected),
680}
681
682/// Converts a full BitMEX order message into a [`ParsedOrderEvent`] for tracked orders.
683///
684/// Returns `None` for transitional statuses (`PendingNew`, `PendingCancel`, `PendingReplace`)
685/// and for fill-related statuses (`PartiallyFilled`, `Filled`, `Rejected`) that are handled
686/// through other channels (Execution table for fills, HTTP response for rejections).
687pub fn parse_order_event(
688    msg: &BitmexOrderMsg,
689    client_order_id: ClientOrderId,
690    account_id: AccountId,
691    trader_id: TraderId,
692    strategy_id: StrategyId,
693    ts_init: UnixNanos,
694) -> Option<ParsedOrderEvent> {
695    let instrument_id = parse_instrument_id(msg.symbol);
696    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
697    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "timestamp");
698
699    match msg.ord_status {
700        BitmexOrderStatus::New => {
701            let accepted = OrderAccepted::new(
702                trader_id,
703                strategy_id,
704                instrument_id,
705                client_order_id,
706                venue_order_id,
707                account_id,
708                UUID4::new(),
709                ts_event,
710                ts_init,
711                false,
712            );
713            Some(ParsedOrderEvent::Accepted(accepted))
714        }
715        BitmexOrderStatus::Canceled => {
716            // BitMEX cancels post-only orders instead of rejecting them when they
717            // would cross the spread. Detect via "ParticipateDoNotInitiate" reason.
718            let cancel_reason = msg
719                .ord_rej_reason
720                .or(msg.text)
721                .map(|r| clean_reason(r.as_ref()));
722
723            let is_post_only_rejection = cancel_reason
724                .as_deref()
725                .is_some_and(|r| r.contains("ParticipateDoNotInitiate"));
726
727            if is_post_only_rejection {
728                let rejected = OrderRejected::new(
729                    trader_id,
730                    strategy_id,
731                    instrument_id,
732                    client_order_id,
733                    account_id,
734                    Ustr::from(
735                        cancel_reason
736                            .as_deref()
737                            .unwrap_or("Post-only order rejected"),
738                    ),
739                    UUID4::new(),
740                    ts_event,
741                    ts_init,
742                    false,
743                    true, // due_post_only
744                );
745                Some(ParsedOrderEvent::Rejected(rejected))
746            } else {
747                let canceled = OrderCanceled::new(
748                    trader_id,
749                    strategy_id,
750                    instrument_id,
751                    client_order_id,
752                    UUID4::new(),
753                    ts_event,
754                    ts_init,
755                    false,
756                    Some(venue_order_id),
757                    Some(account_id),
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 helper 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    // Helper function to create a test perpetual instrument for tests
1220    fn create_test_perpetual_instrument_with_precisions(
1221        price_precision: u8,
1222        size_precision: u8,
1223    ) -> InstrumentAny {
1224        InstrumentAny::CryptoPerpetual(
1225            CryptoPerpetual::builder()
1226                .instrument_id(InstrumentId::from("XBTUSD.BITMEX"))
1227                .raw_symbol(Symbol::new("XBTUSD"))
1228                .base_currency(Currency::BTC())
1229                .quote_currency(Currency::USD())
1230                .settlement_currency(Currency::BTC())
1231                .is_inverse(true)
1232                .price_precision(price_precision)
1233                .size_precision(size_precision)
1234                .price_increment(Price::new(0.5, price_precision))
1235                .size_increment(Quantity::new(1.0, size_precision))
1236                .ts_event(UnixNanos::default())
1237                .ts_init(UnixNanos::default())
1238                .build()
1239                .unwrap(),
1240        )
1241    }
1242
1243    fn create_test_perpetual_instrument() -> InstrumentAny {
1244        create_test_perpetual_instrument_with_precisions(1, 0)
1245    }
1246
1247    #[rstest]
1248    fn test_orderbook_l2_message() {
1249        let json_data = load_test_json("ws_orderbook_l2.json");
1250
1251        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1252        let msg: BitmexOrderBookMsg = serde_json::from_str(&json_data).unwrap();
1253
1254        // Test Insert action
1255        let instrument = create_test_perpetual_instrument();
1256
1257        // Test Insert action (no snapshot flag)
1258        let delta = parse_book_msg(
1259            &msg,
1260            &BitmexAction::Insert,
1261            &instrument,
1262            instrument.id(),
1263            instrument.price_precision(),
1264            UnixNanos::from(3),
1265        );
1266        assert_eq!(delta.instrument_id, instrument_id);
1267        assert_eq!(delta.order.price, Price::from("98459.9"));
1268        assert_eq!(delta.order.size, Quantity::from(33000));
1269        assert_eq!(delta.order.side, OrderSide::Sell.into());
1270        assert_eq!(delta.order.order_id, 62400580205);
1271        assert_eq!(delta.action, BookAction::Add);
1272        assert_eq!(delta.flags, 0);
1273        assert_eq!(delta.sequence, 0);
1274        assert_eq!(delta.ts_event, 1732436782356000000); // 2024-11-24T08:26:22.356Z in nanos
1275        assert_eq!(delta.ts_init, 3);
1276
1277        // Test Partial action (should have F_SNAPSHOT flag)
1278        let delta = parse_book_msg(
1279            &msg,
1280            &BitmexAction::Partial,
1281            &instrument,
1282            instrument.id(),
1283            instrument.price_precision(),
1284            UnixNanos::from(3),
1285        );
1286        assert_eq!(delta.flags, RecordFlag::F_SNAPSHOT as u8);
1287        assert_eq!(delta.action, BookAction::Add);
1288
1289        // Test Update action (no flags)
1290        let delta = parse_book_msg(
1291            &msg,
1292            &BitmexAction::Update,
1293            &instrument,
1294            instrument.id(),
1295            instrument.price_precision(),
1296            UnixNanos::from(3),
1297        );
1298        assert_eq!(delta.flags, 0);
1299        assert_eq!(delta.action, BookAction::Update);
1300    }
1301
1302    #[rstest]
1303    fn test_orderbook10_message() {
1304        let json_data = load_test_json("ws_orderbook_10.json");
1305        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1306        let msg: BitmexOrderBook10Msg = serde_json::from_str(&json_data).unwrap();
1307        let instrument = create_test_perpetual_instrument();
1308        let depth10 = parse_book10_msg(
1309            &msg,
1310            &instrument,
1311            instrument.id(),
1312            instrument.price_precision(),
1313            UnixNanos::from(3),
1314        )
1315        .unwrap();
1316
1317        assert_eq!(depth10.instrument_id, instrument_id);
1318
1319        // Check first bid level
1320        assert_eq!(depth10.bids[0].price, Price::from("98490.3"));
1321        assert_eq!(depth10.bids[0].size, Quantity::from(22400));
1322        assert_eq!(depth10.bids[0].side, OrderSide::Buy.into());
1323
1324        // Check first ask level
1325        assert_eq!(depth10.asks[0].price, Price::from("98490.4"));
1326        assert_eq!(depth10.asks[0].size, Quantity::from(17600));
1327        assert_eq!(depth10.asks[0].side, OrderSide::Sell.into());
1328
1329        // Check counts (should be 1 for each populated level)
1330        assert_eq!(depth10.bid_counts, [1; DEPTH10_LEN]);
1331        assert_eq!(depth10.ask_counts, [1; DEPTH10_LEN]);
1332
1333        // Check flags and timestamps
1334        assert_eq!(depth10.sequence, 0);
1335        assert_eq!(depth10.flags, RecordFlag::F_SNAPSHOT as u8);
1336        assert_eq!(depth10.ts_event, 1732436353513000000); // 2024-11-24T08:19:13.513Z in nanos
1337        assert_eq!(depth10.ts_init, 3);
1338    }
1339
1340    #[rstest]
1341    fn test_quote_message() {
1342        let json_data = load_test_json("ws_quote.json");
1343
1344        let instrument_id = InstrumentId::from("BCHUSDT.BITMEX");
1345        let last_quote = QuoteTick::new(
1346            instrument_id,
1347            Price::new(487.50, 2),
1348            Price::new(488.20, 2),
1349            Quantity::from(100_000),
1350            Quantity::from(100_000),
1351            UnixNanos::from(1),
1352            UnixNanos::from(2),
1353        );
1354        let msg: BitmexQuoteMsg = serde_json::from_str(&json_data).unwrap();
1355        let instrument = create_test_perpetual_instrument_with_precisions(2, 0);
1356        let quote = parse_quote_msg(
1357            &msg,
1358            &last_quote,
1359            &instrument,
1360            instrument_id,
1361            instrument.price_precision(),
1362            UnixNanos::from(3),
1363        );
1364
1365        assert_eq!(quote.instrument_id, instrument_id);
1366        assert_eq!(quote.bid_price, Price::from("487.55"));
1367        assert_eq!(quote.ask_price, Price::from("488.25"));
1368        assert_eq!(quote.bid_size, Quantity::from(103_000));
1369        assert_eq!(quote.ask_size, Quantity::from(50_000));
1370        assert_eq!(quote.ts_event, 1732315465085000000);
1371        assert_eq!(quote.ts_init, 3);
1372    }
1373
1374    #[rstest]
1375    fn test_trade_message() {
1376        let json_data = load_test_json("ws_trade.json");
1377
1378        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1379        let msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1380        let instrument = create_test_perpetual_instrument();
1381        let trade = parse_trade_msg(
1382            &msg,
1383            &instrument,
1384            instrument.id(),
1385            instrument.price_precision(),
1386            UnixNanos::from(3),
1387        );
1388
1389        assert_eq!(trade.instrument_id, instrument_id);
1390        assert_eq!(trade.price, Price::from("98570.9"));
1391        assert_eq!(trade.size, Quantity::from(100));
1392        assert_eq!(trade.aggressor_side, AggressorSide::Sell);
1393        assert_eq!(
1394            trade.trade_id.to_string(),
1395            "00000000-006d-1000-0000-000e8737d536"
1396        );
1397        assert_eq!(trade.ts_event, 1732436138704000000); // 2024-11-24T08:15:38.704Z in nanos
1398        assert_eq!(trade.ts_init, 3);
1399    }
1400
1401    #[rstest]
1402    fn test_trade_message_derives_trade_id_when_trd_match_id_missing() {
1403        let json_data = load_test_json("ws_trade.json");
1404        let mut msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1405        msg.trd_match_id = None;
1406        let instrument = create_test_perpetual_instrument();
1407
1408        let trade = parse_trade_msg(
1409            &msg,
1410            &instrument,
1411            instrument.id(),
1412            instrument.price_precision(),
1413            UnixNanos::from(3),
1414        );
1415
1416        let mut again_msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1417        again_msg.trd_match_id = None;
1418        let again = parse_trade_msg(
1419            &again_msg,
1420            &instrument,
1421            instrument.id(),
1422            instrument.price_precision(),
1423            UnixNanos::from(3),
1424        );
1425
1426        assert_eq!(trade.trade_id, again.trade_id, "derivation must be stable");
1427        assert_eq!(trade.trade_id.as_str().len(), 16);
1428
1429        let mut altered: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1430        altered.trd_match_id = None;
1431        altered.price += 1.0;
1432        let altered_trade = parse_trade_msg(
1433            &altered,
1434            &instrument,
1435            instrument.id(),
1436            instrument.price_precision(),
1437            UnixNanos::from(3),
1438        );
1439        assert_ne!(trade.trade_id, altered_trade.trade_id);
1440    }
1441
1442    #[rstest]
1443    fn test_trade_bin_message() {
1444        let json_data = load_test_json("ws_trade_bin_1m.json");
1445
1446        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1447        let topic = BitmexWsTopic::TradeBin1m;
1448
1449        let msg: BitmexTradeBinMsg = serde_json::from_str(&json_data).unwrap();
1450        let instrument = create_test_perpetual_instrument();
1451        let bar = parse_trade_bin_msg(
1452            &msg,
1453            &topic,
1454            &instrument,
1455            instrument.id(),
1456            instrument.price_precision(),
1457            UnixNanos::from(3),
1458        );
1459
1460        assert_eq!(bar.instrument_id(), instrument_id);
1461        assert_eq!(
1462            bar.bar_type.spec(),
1463            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1464        );
1465        assert_eq!(bar.open, Price::from("97550.0"));
1466        assert_eq!(bar.high, Price::from("97584.4"));
1467        assert_eq!(bar.low, Price::from("97550.0"));
1468        assert_eq!(bar.close, Price::from("97570.1"));
1469        assert_eq!(bar.volume, Quantity::from(84_000));
1470        assert_eq!(bar.ts_event, 1732392420000000000); // 2024-11-23T20:07:00.000Z in nanos
1471        assert_eq!(bar.ts_init, 3);
1472    }
1473
1474    #[rstest]
1475    fn test_trade_bin_message_extreme_adjustment() {
1476        let topic = BitmexWsTopic::TradeBin1m;
1477        let instrument = create_test_perpetual_instrument();
1478
1479        let msg = BitmexTradeBinMsg {
1480            timestamp: "2024-01-01T00:00:00Z".parse::<Timestamp>().unwrap(),
1481            symbol: Ustr::from("XBTUSD"),
1482            open: 50_000.0,
1483            high: 49_990.0,
1484            low: 50_010.0,
1485            close: 50_005.0,
1486            trades: 10,
1487            volume: 1_000,
1488            vwap: Some(0.0),
1489            last_size: Some(0),
1490            turnover: 0,
1491            home_notional: 0.0,
1492            foreign_notional: 0.0,
1493            pool: None,
1494        };
1495
1496        let bar = parse_trade_bin_msg(
1497            &msg,
1498            &topic,
1499            &instrument,
1500            instrument.id(),
1501            instrument.price_precision(),
1502            UnixNanos::from(3),
1503        );
1504
1505        assert_eq!(bar.high, Price::from("50010.0"));
1506        assert_eq!(bar.low, Price::from("49990.0"));
1507        assert_eq!(bar.open, Price::from("50000.0"));
1508        assert_eq!(bar.close, Price::from("50005.0"));
1509        assert_eq!(bar.volume, Quantity::from(1_000));
1510    }
1511
1512    #[rstest]
1513    fn test_parse_order_msg() {
1514        let json_data = load_test_json("ws_order.json");
1515        let mut msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1516        msg.avg_px = Some(Decimal::from_str("30000.500000000004").unwrap());
1517        let mut cache = AHashMap::new();
1518        let instrument = create_test_perpetual_instrument();
1519        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1520
1521        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1522        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1523        assert_eq!(
1524            report.venue_order_id.to_string(),
1525            "550e8400-e29b-41d4-a716-446655440001"
1526        );
1527        assert_eq!(
1528            report.client_order_id.unwrap().to_string(),
1529            "mm_bitmex_1a/oemUeQ4CAJZgP3fjHsA"
1530        );
1531        assert_eq!(report.order_side, OrderSide::Buy.into());
1532        assert_eq!(report.order_type, OrderType::Limit);
1533        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1534        assert_eq!(report.order_status, OrderStatus::Accepted);
1535        assert_eq!(report.quantity, Quantity::from(100));
1536        assert_eq!(report.filled_qty, Quantity::from(0));
1537        assert_eq!(report.price.unwrap(), Price::from("98000.0"));
1538        assert_eq!(
1539            report.avg_px,
1540            Some(Decimal::from_str("30000.500000000004").unwrap())
1541        );
1542        assert_eq!(report.ts_accepted, 1732530600000000000); // 2024-11-25T10:30:00.000Z
1543    }
1544
1545    #[rstest]
1546    fn test_parse_order_msg_infers_type_when_missing() {
1547        let json_data = load_test_json("ws_order.json");
1548        let mut msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1549        msg.ord_type = None;
1550        msg.cl_ord_id = None;
1551        msg.price = Some(98_000.0);
1552        msg.stop_px = None;
1553
1554        let mut cache = AHashMap::new();
1555        let instrument = create_test_perpetual_instrument();
1556
1557        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1558
1559        assert_eq!(report.order_type, OrderType::Limit);
1560    }
1561
1562    #[rstest]
1563    fn test_parse_order_msg_rejected_with_reason() {
1564        let mut msg: BitmexOrderMsg =
1565            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1566        msg.ord_status = BitmexOrderStatus::Rejected;
1567        msg.ord_rej_reason = Some(Ustr::from("Insufficient available balance"));
1568        msg.text = None;
1569        msg.cum_qty = 0;
1570
1571        let mut cache = AHashMap::new();
1572        let instrument = create_test_perpetual_instrument();
1573        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1574
1575        assert_eq!(report.order_status, OrderStatus::Rejected);
1576        assert_eq!(
1577            report.cancel_reason,
1578            Some("Insufficient available balance".to_string())
1579        );
1580    }
1581
1582    #[rstest]
1583    fn test_parse_order_msg_rejected_with_text_fallback() {
1584        let mut msg: BitmexOrderMsg =
1585            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1586        msg.ord_status = BitmexOrderStatus::Rejected;
1587        msg.ord_rej_reason = None;
1588        msg.text = Some(Ustr::from("Order would execute immediately"));
1589        msg.cum_qty = 0;
1590
1591        let mut cache = AHashMap::new();
1592        let instrument = create_test_perpetual_instrument();
1593        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1594
1595        assert_eq!(report.order_status, OrderStatus::Rejected);
1596        assert_eq!(
1597            report.cancel_reason,
1598            Some("Order would execute immediately".to_string())
1599        );
1600    }
1601
1602    #[rstest]
1603    fn test_parse_order_msg_rejected_without_reason() {
1604        let mut msg: BitmexOrderMsg =
1605            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1606        msg.ord_status = BitmexOrderStatus::Rejected;
1607        msg.ord_rej_reason = None;
1608        msg.text = None;
1609        msg.cum_qty = 0;
1610
1611        let mut cache = AHashMap::new();
1612        let instrument = create_test_perpetual_instrument();
1613        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1614
1615        assert_eq!(report.order_status, OrderStatus::Rejected);
1616        assert_eq!(report.cancel_reason, None);
1617    }
1618
1619    #[rstest]
1620    fn test_parse_execution_msg() {
1621        let json_data = load_test_json("ws_execution.json");
1622        let msg: BitmexExecutionMsg = serde_json::from_str(&json_data).unwrap();
1623        let instrument = create_test_perpetual_instrument();
1624        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1625
1626        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1627        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1628        assert_eq!(
1629            fill.venue_order_id.to_string(),
1630            "550e8400-e29b-41d4-a716-446655440002"
1631        );
1632        assert_eq!(
1633            fill.trade_id.to_string(),
1634            "00000000-006d-1000-0000-000e8737d540"
1635        );
1636        assert_eq!(
1637            fill.client_order_id.unwrap().to_string(),
1638            "mm_bitmex_2b/oemUeQ4CAJZgP3fjHsB"
1639        );
1640        assert_eq!(fill.order_side, OrderSide::Sell);
1641        assert_eq!(fill.last_qty, Quantity::from(100));
1642        assert_eq!(fill.last_px, Price::from("98950.0"));
1643        assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
1644        assert_eq!(fill.commission, Money::new(0.00075, Currency::from("XBT")));
1645        assert_eq!(fill.commission.currency.code.to_string(), "XBT");
1646        assert_eq!(fill.ts_event, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1647    }
1648
1649    #[rstest]
1650    fn test_parse_execution_msg_non_trade() {
1651        // Test that non-trade executions return None
1652        let mut msg: BitmexExecutionMsg =
1653            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1654        msg.exec_type = Some(BitmexExecType::Settlement);
1655
1656        let instrument = create_test_perpetual_instrument();
1657        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1658        assert!(result.is_none());
1659    }
1660
1661    #[rstest]
1662    fn test_parse_cancel_reject_execution() {
1663        // Test that CancelReject messages can be parsed (even without symbol)
1664        let json = load_test_json("ws_execution_cancel_reject.json");
1665
1666        let msg: BitmexExecutionMsg = serde_json::from_str(&json).unwrap();
1667        assert_eq!(msg.exec_type, Some(BitmexExecType::CancelReject));
1668        assert_eq!(msg.ord_status, Some(BitmexOrderStatus::Rejected));
1669        assert_eq!(msg.symbol, None);
1670
1671        // Should return None since it's not a Trade
1672        let instrument = create_test_perpetual_instrument();
1673        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1674        assert!(result.is_none());
1675    }
1676
1677    #[rstest]
1678    fn test_parse_execution_msg_liquidation() {
1679        // Critical for ADL/hedge tracking
1680        let mut msg: BitmexExecutionMsg =
1681            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1682        msg.exec_type = Some(BitmexExecType::Liquidation);
1683
1684        let instrument = create_test_perpetual_instrument();
1685        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1686
1687        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1688        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1689        assert_eq!(fill.order_side, OrderSide::Sell);
1690        assert_eq!(fill.last_qty, Quantity::from(100));
1691        assert_eq!(fill.last_px, Price::from("98950.0"));
1692    }
1693
1694    #[rstest]
1695    fn test_parse_execution_msg_bankruptcy() {
1696        let mut msg: BitmexExecutionMsg =
1697            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1698        msg.exec_type = Some(BitmexExecType::Bankruptcy);
1699
1700        let instrument = create_test_perpetual_instrument();
1701        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1702
1703        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1704        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1705        assert_eq!(fill.order_side, OrderSide::Sell);
1706        assert_eq!(fill.last_qty, Quantity::from(100));
1707    }
1708
1709    #[rstest]
1710    fn test_parse_execution_msg_settlement() {
1711        let mut msg: BitmexExecutionMsg =
1712            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1713        msg.exec_type = Some(BitmexExecType::Settlement);
1714
1715        let instrument = create_test_perpetual_instrument();
1716        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1717        assert!(result.is_none());
1718    }
1719
1720    #[rstest]
1721    fn test_parse_execution_msg_trial_fill() {
1722        let mut msg: BitmexExecutionMsg =
1723            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1724        msg.exec_type = Some(BitmexExecType::TrialFill);
1725
1726        let instrument = create_test_perpetual_instrument();
1727        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1728        assert!(result.is_none());
1729    }
1730
1731    #[rstest]
1732    fn test_parse_execution_msg_funding() {
1733        let mut msg: BitmexExecutionMsg =
1734            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1735        msg.exec_type = Some(BitmexExecType::Funding);
1736
1737        let instrument = create_test_perpetual_instrument();
1738        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1739        assert!(result.is_none());
1740    }
1741
1742    #[rstest]
1743    fn test_parse_execution_msg_insurance() {
1744        let mut msg: BitmexExecutionMsg =
1745            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1746        msg.exec_type = Some(BitmexExecType::Insurance);
1747
1748        let instrument = create_test_perpetual_instrument();
1749        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1750        assert!(result.is_none());
1751    }
1752
1753    #[rstest]
1754    fn test_parse_execution_msg_rebalance() {
1755        let mut msg: BitmexExecutionMsg =
1756            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1757        msg.exec_type = Some(BitmexExecType::Rebalance);
1758
1759        let instrument = create_test_perpetual_instrument();
1760        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1761        assert!(result.is_none());
1762    }
1763
1764    #[rstest]
1765    fn test_parse_execution_msg_order_state_changes() {
1766        let instrument = create_test_perpetual_instrument();
1767
1768        let order_state_types = vec![
1769            BitmexExecType::New,
1770            BitmexExecType::Canceled,
1771            BitmexExecType::CancelReject,
1772            BitmexExecType::Replaced,
1773            BitmexExecType::Rejected,
1774            BitmexExecType::AmendReject,
1775            BitmexExecType::Suspended,
1776            BitmexExecType::Released,
1777            BitmexExecType::TriggeredOrActivatedBySystem,
1778        ];
1779
1780        for exec_type in order_state_types {
1781            let mut msg: BitmexExecutionMsg =
1782                serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1783            msg.exec_type = Some(exec_type.clone());
1784
1785            let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1786            assert!(
1787                result.is_none(),
1788                "Expected None for exec_type {exec_type:?}"
1789            );
1790        }
1791    }
1792
1793    #[rstest]
1794    fn test_parse_position_msg() {
1795        let json_data = load_test_json("ws_position.json");
1796        let msg: BitmexPositionMsg = serde_json::from_str(&json_data).unwrap();
1797        let instrument = create_test_perpetual_instrument();
1798        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1799
1800        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1801        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1802        assert_eq!(report.position_side, PositionSide::Long);
1803        assert_eq!(report.quantity, Quantity::from(1000));
1804        assert!(report.venue_position_id.is_none());
1805        assert_eq!(report.ts_last, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1806    }
1807
1808    #[rstest]
1809    fn test_parse_position_msg_short() {
1810        let mut msg: BitmexPositionMsg =
1811            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1812        msg.current_qty = Some(-500);
1813
1814        let instrument = create_test_perpetual_instrument();
1815        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1816        assert_eq!(report.position_side, PositionSide::Short);
1817        assert_eq!(report.quantity, Quantity::from(500));
1818    }
1819
1820    #[rstest]
1821    fn test_parse_position_msg_flat() {
1822        let mut msg: BitmexPositionMsg =
1823            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1824        msg.current_qty = Some(0);
1825
1826        let instrument = create_test_perpetual_instrument();
1827        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1828        assert_eq!(report.position_side, PositionSide::Flat);
1829        assert_eq!(report.quantity, Quantity::from(0));
1830    }
1831
1832    #[rstest]
1833    fn test_parse_wallet_msg() {
1834        let json_data = load_test_json("ws_wallet.json");
1835        let msg: BitmexWalletMsg = serde_json::from_str(&json_data).unwrap();
1836        let ts_init = UnixNanos::from(1);
1837        let account_state = parse_wallet_msg(&msg, ts_init);
1838
1839        assert_eq!(account_state.account_id.to_string(), "BITMEX-1234567");
1840        assert!(!account_state.balances.is_empty());
1841        let balance = &account_state.balances[0];
1842        assert_eq!(balance.currency.code.to_string(), "XBT");
1843        // Amount should be converted from satoshis (100005180 / 100_000_000.0 = 1.0000518)
1844        assert!((balance.total.as_f64() - 1.0000518).abs() < 1e-7);
1845        // Wallet messages do not carry locked margin; full amount is free.
1846        assert_eq!(balance.locked.as_f64(), 0.0);
1847        assert_eq!(balance.free.as_decimal(), balance.total.as_decimal());
1848    }
1849
1850    #[rstest]
1851    fn test_parse_wallet_msg_no_amount() {
1852        let mut msg: BitmexWalletMsg =
1853            serde_json::from_str(&load_test_json("ws_wallet.json")).unwrap();
1854        msg.amount = None;
1855
1856        let ts_init = UnixNanos::from(1);
1857        let account_state = parse_wallet_msg(&msg, ts_init);
1858        let balance = &account_state.balances[0];
1859        assert_eq!(balance.total.as_f64(), 0.0);
1860    }
1861
1862    #[rstest]
1863    fn test_parse_margin_msg() {
1864        let json_data = load_test_json("ws_margin.json");
1865        let msg: BitmexMarginMsg = serde_json::from_str(&json_data).unwrap();
1866        let margin_balance = parse_margin_msg(&msg);
1867
1868        assert_eq!(margin_balance.currency.code.to_string(), "XBT");
1869        assert!(margin_balance.instrument_id.is_none());
1870        // Values should be converted from satoshis to BTC
1871        // initMargin is 0 in test data, so should be 0.0
1872        assert_eq!(margin_balance.initial.as_f64(), 0.0);
1873        // maintMargin is 15949 satoshis = 0.00015949 BTC
1874        assert!((margin_balance.maintenance.as_f64() - 0.00015949).abs() < 1e-8);
1875    }
1876
1877    #[rstest]
1878    fn test_parse_margin_msg_no_available() {
1879        let mut msg: BitmexMarginMsg =
1880            serde_json::from_str(&load_test_json("ws_margin.json")).unwrap();
1881        msg.available_margin = None;
1882
1883        let margin_balance = parse_margin_msg(&msg);
1884        // Should still have valid margin values even if available_margin is None
1885        assert!(margin_balance.initial.as_f64() >= 0.0);
1886        assert!(margin_balance.maintenance.as_f64() >= 0.0);
1887    }
1888
1889    #[rstest]
1890    fn test_parse_margin_account_state_includes_margins() {
1891        let msg = BitmexMarginMsg {
1892            account: 123456,
1893            currency: Ustr::from("USDt"),
1894            risk_limit: None,
1895            amount: Some(5_000_000_000),
1896            prev_realised_pnl: None,
1897            gross_comm: None,
1898            gross_open_cost: None,
1899            gross_open_premium: None,
1900            gross_exec_cost: None,
1901            gross_mark_value: None,
1902            risk_value: None,
1903            init_margin: Some(200_000_000),  // 200 USDT
1904            maint_margin: Some(100_000_000), // 100 USDT
1905            target_excess_margin: None,
1906            realised_pnl: None,
1907            unrealised_pnl: None,
1908            wallet_balance: Some(5_000_000_000), // 5000 USDT
1909            margin_balance: None,
1910            margin_leverage: None,
1911            margin_used_pcnt: None,
1912            excess_margin: None,
1913            available_margin: Some(4_800_000_000), // 4800 USDT
1914            withdrawable_margin: None,
1915            maker_fee_discount: None,
1916            taker_fee_discount: None,
1917            timestamp: Timestamp::from_second(1_700_000_000).unwrap(),
1918            foreign_margin_balance: None,
1919            foreign_requirement: None,
1920        };
1921
1922        let ts_init = UnixNanos::from(1_000_000_000u64);
1923        let state = parse_margin_account_state(&msg, ts_init);
1924
1925        assert_eq!(state.account_id.to_string(), "BITMEX-123456");
1926        assert_eq!(state.account_type, AccountType::Margin);
1927        assert_eq!(state.balances.len(), 1);
1928        assert_eq!(state.margins.len(), 1);
1929
1930        let balance = &state.balances[0];
1931        assert_eq!(balance.total.as_f64(), 5000.0);
1932
1933        let margin = &state.margins[0];
1934        assert!(margin.instrument_id.is_none());
1935        assert_eq!(margin.currency.code.as_str(), "USDT");
1936        assert_eq!(margin.initial.as_f64(), 200.0);
1937        assert_eq!(margin.maintenance.as_f64(), 100.0);
1938    }
1939
1940    #[rstest]
1941    fn test_parse_margin_account_state_zero_margins_excluded() {
1942        let msg = BitmexMarginMsg {
1943            account: 123456,
1944            currency: Ustr::from("XBt"),
1945            risk_limit: None,
1946            amount: Some(100_000_000),
1947            prev_realised_pnl: None,
1948            gross_comm: None,
1949            gross_open_cost: None,
1950            gross_open_premium: None,
1951            gross_exec_cost: None,
1952            gross_mark_value: None,
1953            risk_value: None,
1954            init_margin: Some(0),
1955            maint_margin: Some(0),
1956            target_excess_margin: None,
1957            realised_pnl: None,
1958            unrealised_pnl: None,
1959            wallet_balance: Some(100_000_000),
1960            margin_balance: None,
1961            margin_leverage: None,
1962            margin_used_pcnt: None,
1963            excess_margin: None,
1964            available_margin: Some(100_000_000),
1965            withdrawable_margin: None,
1966            maker_fee_discount: None,
1967            taker_fee_discount: None,
1968            timestamp: Timestamp::from_second(1_700_000_000).unwrap(),
1969            foreign_margin_balance: None,
1970            foreign_requirement: None,
1971        };
1972
1973        let state = parse_margin_account_state(&msg, UnixNanos::from(1_000_000_000u64));
1974
1975        assert_eq!(state.balances.len(), 1);
1976        assert_eq!(state.margins.len(), 0);
1977    }
1978
1979    #[rstest]
1980    fn test_parse_instrument_msg_both_prices() {
1981        let json_data = load_test_json("ws_instrument.json");
1982        let msg: BitmexInstrumentMsg = serde_json::from_str(&json_data).unwrap();
1983
1984        // Create cache with test instrument
1985        let mut instruments_cache = AHashMap::new();
1986        let test_instrument = create_test_perpetual_instrument();
1987        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
1988
1989        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
1990
1991        // Mark comes from `markPrice` (95125.7); index from `indicativeSettlePrice` (95126.0).
1992        assert_eq!(updates.len(), 2);
1993
1994        match &updates[0] {
1995            Data::MarkPrice(update) => {
1996                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
1997                assert_eq!(update.value.as_f64(), 95125.7);
1998            }
1999            _ => panic!("Expected MarkPriceUpdate at index 0"),
2000        }
2001
2002        match &updates[1] {
2003            Data::IndexPrice(update) => {
2004                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2005                assert_eq!(update.value.as_f64(), 95126.0);
2006            }
2007            _ => panic!("Expected IndexPriceUpdate at index 1"),
2008        }
2009    }
2010
2011    #[rstest]
2012    fn test_parse_instrument_msg_mark_price_only() {
2013        let mut msg: BitmexInstrumentMsg =
2014            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2015        msg.index_price = None;
2016        msg.indicative_settle_price = None;
2017
2018        let mut instruments_cache = AHashMap::new();
2019        let test_instrument = create_test_perpetual_instrument();
2020        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2021
2022        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2023
2024        assert_eq!(updates.len(), 1);
2025        match &updates[0] {
2026            Data::MarkPrice(update) => {
2027                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2028                assert_eq!(update.value.as_f64(), 95125.7);
2029            }
2030            _ => panic!("Expected MarkPriceUpdate"),
2031        }
2032    }
2033
2034    #[rstest]
2035    fn test_parse_instrument_msg_index_price_only() {
2036        let mut msg: BitmexInstrumentMsg =
2037            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2038        msg.mark_price = None;
2039        msg.fair_price = None;
2040
2041        let mut instruments_cache = AHashMap::new();
2042        let test_instrument = create_test_perpetual_instrument();
2043        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2044
2045        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2046
2047        assert_eq!(updates.len(), 1);
2048        match &updates[0] {
2049            Data::IndexPrice(update) => {
2050                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2051                assert_eq!(update.value.as_f64(), 95126.0);
2052            }
2053            _ => panic!("Expected IndexPriceUpdate"),
2054        }
2055    }
2056
2057    #[rstest]
2058    fn test_parse_instrument_msg_no_prices() {
2059        let mut msg: BitmexInstrumentMsg =
2060            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2061        msg.mark_price = None;
2062        msg.fair_price = None;
2063        msg.index_price = None;
2064        msg.indicative_settle_price = None;
2065        msg.last_price = None;
2066
2067        // Create cache with test instrument
2068        let mut instruments_cache = AHashMap::new();
2069        let test_instrument = create_test_perpetual_instrument();
2070        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2071
2072        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2073        assert_eq!(updates.len(), 0);
2074    }
2075
2076    #[rstest]
2077    fn test_parse_instrument_msg_index_symbol() {
2078        // Test for index symbols like .BXBT where lastPrice is the index price
2079        // and markPrice equals lastPrice
2080        let mut msg: BitmexInstrumentMsg =
2081            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2082        msg.symbol = Ustr::from(".BXBT");
2083        msg.last_price = Some(119163.05);
2084        msg.mark_price = Some(119163.05); // Index symbols have mark price equal to last price
2085        msg.fair_price = None;
2086        msg.index_price = None;
2087        msg.indicative_settle_price = None;
2088
2089        // Create instruments cache with proper precision for .BXBT
2090        let instrument_id = InstrumentId::from(".BXBT.BITMEX");
2091        let instrument = CryptoPerpetual::builder()
2092            .instrument_id(instrument_id)
2093            .raw_symbol(Symbol::from(".BXBT"))
2094            .base_currency(Currency::BTC())
2095            .quote_currency(Currency::USD())
2096            .settlement_currency(Currency::USD())
2097            .is_inverse(false)
2098            // price_precision (for 119163.05)
2099            .price_precision(2)
2100            .size_precision(8)
2101            .price_increment(Price::from("0.01"))
2102            .size_increment(Quantity::from("0.00000001"))
2103            .ts_event(UnixNanos::default())
2104            .ts_init(UnixNanos::default())
2105            .build()
2106            .unwrap();
2107        let mut instruments_cache = AHashMap::new();
2108        instruments_cache.insert(
2109            Ustr::from(".BXBT"),
2110            InstrumentAny::CryptoPerpetual(instrument),
2111        );
2112
2113        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2114
2115        assert_eq!(updates.len(), 2);
2116
2117        // Check mark price update
2118        match &updates[0] {
2119            Data::MarkPrice(update) => {
2120                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2121                assert_eq!(update.value, Price::from("119163.05"));
2122            }
2123            _ => panic!("Expected MarkPriceUpdate for index symbol"),
2124        }
2125
2126        // Check index price update
2127        match &updates[1] {
2128            Data::IndexPrice(update) => {
2129                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2130                assert_eq!(update.value, Price::from("119163.05"));
2131                assert_eq!(update.ts_init, UnixNanos::from(1));
2132            }
2133            _ => panic!("Expected IndexPriceUpdate for index symbol"),
2134        }
2135    }
2136
2137    /// Real-wire mark-only update: pins fairPrice preference.
2138    #[rstest]
2139    fn test_parse_instrument_msg_mark_update_wire_shape() {
2140        let msg: BitmexInstrumentMsg =
2141            serde_json::from_str(&load_test_json("ws_instrument_mark_update.json")).unwrap();
2142
2143        let instrument_id = InstrumentId::from("DOTUSDT.BITMEX");
2144        let instrument = CryptoPerpetual::builder()
2145            .instrument_id(instrument_id)
2146            .raw_symbol(Symbol::from("DOTUSDT"))
2147            .base_currency(Currency::from_str("DOT").unwrap())
2148            .quote_currency(Currency::USDT())
2149            .settlement_currency(Currency::USDT())
2150            .is_inverse(false)
2151            // price_precision (1.2669)
2152            .price_precision(4)
2153            .size_precision(8)
2154            .price_increment(Price::from("0.0001"))
2155            .size_increment(Quantity::from("0.00000001"))
2156            .ts_event(UnixNanos::default())
2157            .ts_init(UnixNanos::default())
2158            .build()
2159            .unwrap();
2160        let mut instruments_cache = AHashMap::new();
2161        instruments_cache.insert(
2162            Ustr::from("DOTUSDT"),
2163            InstrumentAny::CryptoPerpetual(instrument),
2164        );
2165
2166        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2167
2168        assert_eq!(updates.len(), 1);
2169        match &updates[0] {
2170            Data::MarkPrice(update) => {
2171                assert_eq!(update.instrument_id.to_string(), "DOTUSDT.BITMEX");
2172                assert_eq!(update.value, Price::from("1.2669"));
2173            }
2174            _ => panic!("Expected single MarkPriceUpdate for mark-update wire shape"),
2175        }
2176    }
2177
2178    /// Real-wire index-only update: regression for indicativeSettlePrice routing.
2179    #[rstest]
2180    fn test_parse_instrument_msg_index_update_wire_shape() {
2181        let msg: BitmexInstrumentMsg =
2182            serde_json::from_str(&load_test_json("ws_instrument_index_update.json")).unwrap();
2183
2184        let mut instruments_cache = AHashMap::new();
2185        instruments_cache.insert(
2186            Ustr::from("XBTUSD"),
2187            create_test_perpetual_instrument_with_precisions(2, 0),
2188        );
2189
2190        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2191
2192        assert_eq!(updates.len(), 1);
2193        match &updates[0] {
2194            Data::IndexPrice(update) => {
2195                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2196                assert_eq!(update.value, Price::from("75847.62"));
2197            }
2198            _ => panic!("Expected single IndexPriceUpdate for index-update wire shape"),
2199        }
2200    }
2201
2202    #[rstest]
2203    fn test_parse_funding_msg() {
2204        let json_data = load_test_json("ws_funding_rate.json");
2205        let msg: BitmexFundingMsg = serde_json::from_str(&json_data).unwrap();
2206        let update = parse_funding_msg(&msg, UnixNanos::from(1));
2207
2208        assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2209        assert_eq!(update.rate.to_string(), "0.0001");
2210        assert_eq!(update.interval, Some(60 * 8));
2211        assert!(update.next_funding_ns.is_none());
2212        assert_eq!(update.ts_event, UnixNanos::from(1732507200000000000));
2213        assert_eq!(update.ts_init, UnixNanos::from(1));
2214    }
2215}