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 chrono::Timelike;
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 = common_side.into();
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,
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        .map(|p| Price::new(p, instrument.price_precision()));
812
813    // BitMEX doesn't send trigger price in regular order updates?
814    let trigger_price = None;
815    // BitMEX doesn't send protection price in regular order updates
816    let protection_price = None;
817
818    let event_id = UUID4::new();
819    let ts_event = parse_optional_datetime_to_unix_nanos(&msg.timestamp, "timestamp");
820
821    Some(OrderUpdated::new(
822        trader_id,
823        strategy_id,
824        instrument_id,
825        client_order_id,
826        quantity,
827        event_id,
828        ts_event,
829        ts_init,
830        false, // reconciliation
831        venue_order_id,
832        Some(account_id),
833        price,
834        trigger_price,
835        protection_price,
836        false, // is_quote_quantity
837    ))
838}
839
840/// Parse a BitMEX WebSocket execution message into a Nautilus `FillReport`.
841///
842/// Handles different execution types appropriately:
843/// - `Trade`: Normal trade execution → FillReport
844/// - `Liquidation`: Auto-deleveraging or liquidation → FillReport
845/// - `Bankruptcy`: Bankruptcy execution → FillReport (with warning)
846/// - `Settlement`, `TrialFill`: Non-obvious cases → None (with warning)
847/// - `Funding`, `Insurance`, `Rebalance`: Expected non-fills → None (debug log)
848/// - Order state changes (`New`, `Canceled`, etc.): → None (debug log)
849///
850/// # References
851///
852/// <https://www.bitmex.com/app/wsAPI#Execution>
853pub fn parse_execution_msg(
854    msg: BitmexExecutionMsg,
855    instrument: &InstrumentAny,
856    ts_init: UnixNanos,
857) -> Option<FillReport> {
858    let exec_type = msg.exec_type?;
859
860    match exec_type {
861        // Position-affecting executions that generate fills
862        BitmexExecType::Trade | BitmexExecType::Liquidation => {}
863        BitmexExecType::Bankruptcy => {
864            log::warn!(
865                "Processing bankruptcy execution as fill: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
866                msg.order_id,
867                msg.symbol,
868            );
869        }
870
871        // Settlement executions are mark-to-market events, not fills
872        BitmexExecType::Settlement => {
873            log::debug!(
874                "Settlement execution skipped (not a fill): applies quanto conversion/PnL transfer on contract settlement: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
875                msg.order_id,
876                msg.symbol,
877            );
878            return None;
879        }
880        BitmexExecType::TrialFill => {
881            log::warn!(
882                "Trial fill execution received (testnet only), not processed as fill: exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
883                msg.order_id,
884                msg.symbol,
885            );
886            return None;
887        }
888
889        // Expected non-fill executions
890        BitmexExecType::Funding => {
891            log::debug!(
892                "Funding execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
893                msg.order_id,
894                msg.symbol,
895            );
896            return None;
897        }
898        BitmexExecType::Insurance => {
899            log::debug!(
900                "Insurance execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
901                msg.order_id,
902                msg.symbol,
903            );
904            return None;
905        }
906        BitmexExecType::Rebalance => {
907            log::debug!(
908                "Rebalance execution skipped (not a fill): exec_type={exec_type:?}, order_id={:?}, symbol={:?}",
909                msg.order_id,
910                msg.symbol,
911            );
912            return None;
913        }
914
915        // Order state changes (not fills)
916        BitmexExecType::New
917        | BitmexExecType::Canceled
918        | BitmexExecType::CancelReject
919        | BitmexExecType::Replaced
920        | BitmexExecType::Rejected
921        | BitmexExecType::AmendReject
922        | BitmexExecType::Suspended
923        | BitmexExecType::Released
924        | BitmexExecType::TriggeredOrActivatedBySystem => {
925            log::debug!(
926                "Execution message skipped (order state change, not a fill): exec_type={exec_type:?}, order_id={:?}",
927                msg.order_id,
928            );
929            return None;
930        }
931
932        BitmexExecType::Unknown(ref type_str) => {
933            log::warn!(
934                "Unknown execution type received, skipping: exec_type={type_str}, order_id={:?}, symbol={:?}",
935                msg.order_id,
936                msg.symbol,
937            );
938            return None;
939        }
940    }
941
942    let account_id = bitmex_account_id(msg.account?);
943    let instrument_id = parse_instrument_id(msg.symbol?);
944    let venue_order_id = VenueOrderId::new(msg.order_id?.to_string());
945    let trade_id = TradeId::new(msg.trd_match_id?.to_string());
946    let order_side: OrderSide = msg.side.map_or(OrderSide::NoOrderSide, |s| {
947        let side: BitmexSide = s.into();
948        side.into()
949    });
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).as_specified();
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(&Ustr::from(&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::MarkPriceUpdate(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::IndexPriceUpdate(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 interval_hours = msg.funding_interval.hour();
1104    let interval_minutes = msg.funding_interval.minute();
1105    let interval = Some((interval_hours * 60 + interval_minutes) as u16);
1106    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "");
1107
1108    FundingRateUpdate::new(
1109        instrument_id,
1110        msg.funding_rate,
1111        interval,
1112        None, // Next funding time not provided in this message
1113        ts_event,
1114        ts_init,
1115    )
1116}
1117
1118/// Parse a BitMEX wallet message into an AccountState.
1119///
1120/// BitMEX uses XBT (satoshis) as the base unit for Bitcoin.
1121/// 1 XBT = 0.00000001 BTC (1 satoshi).
1122///
1123/// # Panics
1124///
1125/// Panics if the balance calculation is invalid (total != locked + free).
1126#[must_use]
1127pub fn parse_wallet_msg(msg: &BitmexWalletMsg, ts_init: UnixNanos) -> AccountState {
1128    let account_id = bitmex_account_id(msg.account);
1129
1130    // Map BitMEX currency to standard currency code
1131    let currency_str = map_bitmex_currency(msg.currency.as_str());
1132    let currency = get_currency(&currency_str);
1133
1134    // Wallet messages do not expose locked margin; treat the full balance as free
1135    // and let the centralized helper enforce `total == locked + free` at currency precision.
1136    let divisor = bitmex_currency_divisor(msg.currency.as_str());
1137    let amount_dec = Decimal::from(msg.amount.unwrap_or(0)) / divisor;
1138
1139    let balance = AccountBalance::from_total_and_locked(amount_dec, Decimal::ZERO, currency)
1140        .expect("Balance calculation should be valid");
1141
1142    AccountState::new(
1143        account_id,
1144        AccountType::Margin,
1145        vec![balance],
1146        vec![], // margins will be added separately
1147        true,   // is_reported
1148        UUID4::new(),
1149        ts_init,
1150        ts_init,
1151        None,
1152    )
1153}
1154
1155/// Parse a BitMEX margin message into an account-wide [`MarginBalance`].
1156#[must_use]
1157pub fn parse_margin_msg(msg: &BitmexMarginMsg) -> MarginBalance {
1158    let currency_str = map_bitmex_currency(msg.currency.as_str());
1159    let currency = get_currency(&currency_str);
1160
1161    let divisor = bitmex_currency_divisor(msg.currency.as_str());
1162    let initial_dec = Decimal::from(msg.init_margin.unwrap_or(0).max(0)) / divisor;
1163    let maintenance_dec = Decimal::from(msg.maint_margin.unwrap_or(0).max(0)) / divisor;
1164
1165    MarginBalance::new(
1166        Money::from_decimal(initial_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
1167        Money::from_decimal(maintenance_dec, currency).unwrap_or_else(|_| Money::zero(currency)),
1168        None,
1169    )
1170}
1171
1172/// Parses a BitMEX margin message into an [`AccountState`] with balances and margins.
1173#[must_use]
1174pub fn parse_margin_account_state(msg: &BitmexMarginMsg, ts_init: UnixNanos) -> AccountState {
1175    let account_id = bitmex_account_id(msg.account);
1176    let balance = parse_account_balance(msg);
1177
1178    let margin = parse_margin_msg(msg);
1179
1180    let margins = if !margin.initial.is_zero() || !margin.maintenance.is_zero() {
1181        vec![margin]
1182    } else {
1183        vec![]
1184    };
1185
1186    let ts_event = parse_optional_datetime_to_unix_nanos(&Some(msg.timestamp), "margin.timestamp");
1187
1188    AccountState::new(
1189        account_id,
1190        AccountType::Margin,
1191        vec![balance],
1192        margins,
1193        true,
1194        UUID4::new(),
1195        ts_event,
1196        ts_init,
1197        None,
1198    )
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use chrono::{DateTime, Utc};
1204    use nautilus_model::{
1205        enums::{AggressorSide, BookAction, LiquiditySide, PositionSide},
1206        identifiers::Symbol,
1207        instruments::crypto_perpetual::CryptoPerpetual,
1208    };
1209    use rstest::rstest;
1210    use ustr::Ustr;
1211
1212    use super::*;
1213    use crate::common::{
1214        enums::{BitmexExecType, BitmexOrderStatus},
1215        testing::load_test_json,
1216    };
1217
1218    // Helper function to create a test perpetual instrument for tests
1219    fn create_test_perpetual_instrument_with_precisions(
1220        price_precision: u8,
1221        size_precision: u8,
1222    ) -> InstrumentAny {
1223        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1224            InstrumentId::from("XBTUSD.BITMEX"),
1225            Symbol::new("XBTUSD"),
1226            Currency::BTC(),
1227            Currency::USD(),
1228            Currency::BTC(),
1229            true, // is_inverse
1230            price_precision,
1231            size_precision,
1232            Price::new(0.5, price_precision),
1233            Quantity::new(1.0, size_precision),
1234            None, // multiplier
1235            None, // lot_size
1236            None, // max_quantity
1237            None, // min_quantity
1238            None, // max_notional
1239            None, // min_notional
1240            None, // max_price
1241            None, // min_price
1242            None, // margin_init
1243            None, // margin_maint
1244            None, // maker_fee
1245            None, // taker_fee
1246            None, // tick_scheme
1247            None, // info
1248            UnixNanos::default(),
1249            UnixNanos::default(),
1250        ))
1251    }
1252
1253    fn create_test_perpetual_instrument() -> InstrumentAny {
1254        create_test_perpetual_instrument_with_precisions(1, 0)
1255    }
1256
1257    #[rstest]
1258    fn test_orderbook_l2_message() {
1259        let json_data = load_test_json("ws_orderbook_l2.json");
1260
1261        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1262        let msg: BitmexOrderBookMsg = serde_json::from_str(&json_data).unwrap();
1263
1264        // Test Insert action
1265        let instrument = create_test_perpetual_instrument();
1266
1267        // Test Insert action (no snapshot flag)
1268        let delta = parse_book_msg(
1269            &msg,
1270            &BitmexAction::Insert,
1271            &instrument,
1272            instrument.id(),
1273            instrument.price_precision(),
1274            UnixNanos::from(3),
1275        );
1276        assert_eq!(delta.instrument_id, instrument_id);
1277        assert_eq!(delta.order.price, Price::from("98459.9"));
1278        assert_eq!(delta.order.size, Quantity::from(33000));
1279        assert_eq!(delta.order.side, OrderSide::Sell);
1280        assert_eq!(delta.order.order_id, 62400580205);
1281        assert_eq!(delta.action, BookAction::Add);
1282        assert_eq!(delta.flags, 0);
1283        assert_eq!(delta.sequence, 0);
1284        assert_eq!(delta.ts_event, 1732436782356000000); // 2024-11-24T08:26:22.356Z in nanos
1285        assert_eq!(delta.ts_init, 3);
1286
1287        // Test Partial action (should have F_SNAPSHOT flag)
1288        let delta = parse_book_msg(
1289            &msg,
1290            &BitmexAction::Partial,
1291            &instrument,
1292            instrument.id(),
1293            instrument.price_precision(),
1294            UnixNanos::from(3),
1295        );
1296        assert_eq!(delta.flags, RecordFlag::F_SNAPSHOT as u8);
1297        assert_eq!(delta.action, BookAction::Add);
1298
1299        // Test Update action (no flags)
1300        let delta = parse_book_msg(
1301            &msg,
1302            &BitmexAction::Update,
1303            &instrument,
1304            instrument.id(),
1305            instrument.price_precision(),
1306            UnixNanos::from(3),
1307        );
1308        assert_eq!(delta.flags, 0);
1309        assert_eq!(delta.action, BookAction::Update);
1310    }
1311
1312    #[rstest]
1313    fn test_orderbook10_message() {
1314        let json_data = load_test_json("ws_orderbook_10.json");
1315        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1316        let msg: BitmexOrderBook10Msg = serde_json::from_str(&json_data).unwrap();
1317        let instrument = create_test_perpetual_instrument();
1318        let depth10 = parse_book10_msg(
1319            &msg,
1320            &instrument,
1321            instrument.id(),
1322            instrument.price_precision(),
1323            UnixNanos::from(3),
1324        )
1325        .unwrap();
1326
1327        assert_eq!(depth10.instrument_id, instrument_id);
1328
1329        // Check first bid level
1330        assert_eq!(depth10.bids[0].price, Price::from("98490.3"));
1331        assert_eq!(depth10.bids[0].size, Quantity::from(22400));
1332        assert_eq!(depth10.bids[0].side, OrderSide::Buy);
1333
1334        // Check first ask level
1335        assert_eq!(depth10.asks[0].price, Price::from("98490.4"));
1336        assert_eq!(depth10.asks[0].size, Quantity::from(17600));
1337        assert_eq!(depth10.asks[0].side, OrderSide::Sell);
1338
1339        // Check counts (should be 1 for each populated level)
1340        assert_eq!(depth10.bid_counts, [1; DEPTH10_LEN]);
1341        assert_eq!(depth10.ask_counts, [1; DEPTH10_LEN]);
1342
1343        // Check flags and timestamps
1344        assert_eq!(depth10.sequence, 0);
1345        assert_eq!(depth10.flags, RecordFlag::F_SNAPSHOT as u8);
1346        assert_eq!(depth10.ts_event, 1732436353513000000); // 2024-11-24T08:19:13.513Z in nanos
1347        assert_eq!(depth10.ts_init, 3);
1348    }
1349
1350    #[rstest]
1351    fn test_quote_message() {
1352        let json_data = load_test_json("ws_quote.json");
1353
1354        let instrument_id = InstrumentId::from("BCHUSDT.BITMEX");
1355        let last_quote = QuoteTick::new(
1356            instrument_id,
1357            Price::new(487.50, 2),
1358            Price::new(488.20, 2),
1359            Quantity::from(100_000),
1360            Quantity::from(100_000),
1361            UnixNanos::from(1),
1362            UnixNanos::from(2),
1363        );
1364        let msg: BitmexQuoteMsg = serde_json::from_str(&json_data).unwrap();
1365        let instrument = create_test_perpetual_instrument_with_precisions(2, 0);
1366        let quote = parse_quote_msg(
1367            &msg,
1368            &last_quote,
1369            &instrument,
1370            instrument_id,
1371            instrument.price_precision(),
1372            UnixNanos::from(3),
1373        );
1374
1375        assert_eq!(quote.instrument_id, instrument_id);
1376        assert_eq!(quote.bid_price, Price::from("487.55"));
1377        assert_eq!(quote.ask_price, Price::from("488.25"));
1378        assert_eq!(quote.bid_size, Quantity::from(103_000));
1379        assert_eq!(quote.ask_size, Quantity::from(50_000));
1380        assert_eq!(quote.ts_event, 1732315465085000000);
1381        assert_eq!(quote.ts_init, 3);
1382    }
1383
1384    #[rstest]
1385    fn test_trade_message() {
1386        let json_data = load_test_json("ws_trade.json");
1387
1388        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1389        let msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1390        let instrument = create_test_perpetual_instrument();
1391        let trade = parse_trade_msg(
1392            &msg,
1393            &instrument,
1394            instrument.id(),
1395            instrument.price_precision(),
1396            UnixNanos::from(3),
1397        );
1398
1399        assert_eq!(trade.instrument_id, instrument_id);
1400        assert_eq!(trade.price, Price::from("98570.9"));
1401        assert_eq!(trade.size, Quantity::from(100));
1402        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1403        assert_eq!(
1404            trade.trade_id.to_string(),
1405            "00000000-006d-1000-0000-000e8737d536"
1406        );
1407        assert_eq!(trade.ts_event, 1732436138704000000); // 2024-11-24T08:15:38.704Z in nanos
1408        assert_eq!(trade.ts_init, 3);
1409    }
1410
1411    #[rstest]
1412    fn test_trade_message_derives_trade_id_when_trd_match_id_missing() {
1413        let json_data = load_test_json("ws_trade.json");
1414        let mut msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1415        msg.trd_match_id = None;
1416        let instrument = create_test_perpetual_instrument();
1417
1418        let trade = parse_trade_msg(
1419            &msg,
1420            &instrument,
1421            instrument.id(),
1422            instrument.price_precision(),
1423            UnixNanos::from(3),
1424        );
1425
1426        let mut again_msg: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1427        again_msg.trd_match_id = None;
1428        let again = parse_trade_msg(
1429            &again_msg,
1430            &instrument,
1431            instrument.id(),
1432            instrument.price_precision(),
1433            UnixNanos::from(3),
1434        );
1435
1436        assert_eq!(trade.trade_id, again.trade_id, "derivation must be stable");
1437        assert_eq!(trade.trade_id.as_str().len(), 16);
1438
1439        let mut altered: BitmexTradeMsg = serde_json::from_str(&json_data).unwrap();
1440        altered.trd_match_id = None;
1441        altered.price += 1.0;
1442        let altered_trade = parse_trade_msg(
1443            &altered,
1444            &instrument,
1445            instrument.id(),
1446            instrument.price_precision(),
1447            UnixNanos::from(3),
1448        );
1449        assert_ne!(trade.trade_id, altered_trade.trade_id);
1450    }
1451
1452    #[rstest]
1453    fn test_trade_bin_message() {
1454        let json_data = load_test_json("ws_trade_bin_1m.json");
1455
1456        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1457        let topic = BitmexWsTopic::TradeBin1m;
1458
1459        let msg: BitmexTradeBinMsg = serde_json::from_str(&json_data).unwrap();
1460        let instrument = create_test_perpetual_instrument();
1461        let bar = parse_trade_bin_msg(
1462            &msg,
1463            &topic,
1464            &instrument,
1465            instrument.id(),
1466            instrument.price_precision(),
1467            UnixNanos::from(3),
1468        );
1469
1470        assert_eq!(bar.instrument_id(), instrument_id);
1471        assert_eq!(
1472            bar.bar_type.spec(),
1473            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1474        );
1475        assert_eq!(bar.open, Price::from("97550.0"));
1476        assert_eq!(bar.high, Price::from("97584.4"));
1477        assert_eq!(bar.low, Price::from("97550.0"));
1478        assert_eq!(bar.close, Price::from("97570.1"));
1479        assert_eq!(bar.volume, Quantity::from(84_000));
1480        assert_eq!(bar.ts_event, 1732392420000000000); // 2024-11-23T20:07:00.000Z in nanos
1481        assert_eq!(bar.ts_init, 3);
1482    }
1483
1484    #[rstest]
1485    fn test_trade_bin_message_extreme_adjustment() {
1486        let topic = BitmexWsTopic::TradeBin1m;
1487        let instrument = create_test_perpetual_instrument();
1488
1489        let msg = BitmexTradeBinMsg {
1490            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1491                .unwrap()
1492                .with_timezone(&Utc),
1493            symbol: Ustr::from("XBTUSD"),
1494            open: 50_000.0,
1495            high: 49_990.0,
1496            low: 50_010.0,
1497            close: 50_005.0,
1498            trades: 10,
1499            volume: 1_000,
1500            vwap: Some(0.0),
1501            last_size: Some(0),
1502            turnover: 0,
1503            home_notional: 0.0,
1504            foreign_notional: 0.0,
1505            pool: None,
1506        };
1507
1508        let bar = parse_trade_bin_msg(
1509            &msg,
1510            &topic,
1511            &instrument,
1512            instrument.id(),
1513            instrument.price_precision(),
1514            UnixNanos::from(3),
1515        );
1516
1517        assert_eq!(bar.high, Price::from("50010.0"));
1518        assert_eq!(bar.low, Price::from("49990.0"));
1519        assert_eq!(bar.open, Price::from("50000.0"));
1520        assert_eq!(bar.close, Price::from("50005.0"));
1521        assert_eq!(bar.volume, Quantity::from(1_000));
1522    }
1523
1524    #[rstest]
1525    fn test_parse_order_msg() {
1526        let json_data = load_test_json("ws_order.json");
1527        let msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1528        let mut cache = AHashMap::new();
1529        let instrument = create_test_perpetual_instrument();
1530        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1531
1532        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1533        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1534        assert_eq!(
1535            report.venue_order_id.to_string(),
1536            "550e8400-e29b-41d4-a716-446655440001"
1537        );
1538        assert_eq!(
1539            report.client_order_id.unwrap().to_string(),
1540            "mm_bitmex_1a/oemUeQ4CAJZgP3fjHsA"
1541        );
1542        assert_eq!(report.order_side, OrderSide::Buy);
1543        assert_eq!(report.order_type, OrderType::Limit);
1544        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1545        assert_eq!(report.order_status, OrderStatus::Accepted);
1546        assert_eq!(report.quantity, Quantity::from(100));
1547        assert_eq!(report.filled_qty, Quantity::from(0));
1548        assert_eq!(report.price.unwrap(), Price::from("98000.0"));
1549        assert_eq!(report.ts_accepted, 1732530600000000000); // 2024-11-25T10:30:00.000Z
1550    }
1551
1552    #[rstest]
1553    fn test_parse_order_msg_infers_type_when_missing() {
1554        let json_data = load_test_json("ws_order.json");
1555        let mut msg: BitmexOrderMsg = serde_json::from_str(&json_data).unwrap();
1556        msg.ord_type = None;
1557        msg.cl_ord_id = None;
1558        msg.price = Some(98_000.0);
1559        msg.stop_px = None;
1560
1561        let mut cache = AHashMap::new();
1562        let instrument = create_test_perpetual_instrument();
1563
1564        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1565
1566        assert_eq!(report.order_type, OrderType::Limit);
1567    }
1568
1569    #[rstest]
1570    fn test_parse_order_msg_rejected_with_reason() {
1571        let mut msg: BitmexOrderMsg =
1572            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1573        msg.ord_status = BitmexOrderStatus::Rejected;
1574        msg.ord_rej_reason = Some(Ustr::from("Insufficient available balance"));
1575        msg.text = None;
1576        msg.cum_qty = 0;
1577
1578        let mut cache = AHashMap::new();
1579        let instrument = create_test_perpetual_instrument();
1580        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1581
1582        assert_eq!(report.order_status, OrderStatus::Rejected);
1583        assert_eq!(
1584            report.cancel_reason,
1585            Some("Insufficient available balance".to_string())
1586        );
1587    }
1588
1589    #[rstest]
1590    fn test_parse_order_msg_rejected_with_text_fallback() {
1591        let mut msg: BitmexOrderMsg =
1592            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1593        msg.ord_status = BitmexOrderStatus::Rejected;
1594        msg.ord_rej_reason = None;
1595        msg.text = Some(Ustr::from("Order would execute immediately"));
1596        msg.cum_qty = 0;
1597
1598        let mut cache = AHashMap::new();
1599        let instrument = create_test_perpetual_instrument();
1600        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1601
1602        assert_eq!(report.order_status, OrderStatus::Rejected);
1603        assert_eq!(
1604            report.cancel_reason,
1605            Some("Order would execute immediately".to_string())
1606        );
1607    }
1608
1609    #[rstest]
1610    fn test_parse_order_msg_rejected_without_reason() {
1611        let mut msg: BitmexOrderMsg =
1612            serde_json::from_str(&load_test_json("ws_order.json")).unwrap();
1613        msg.ord_status = BitmexOrderStatus::Rejected;
1614        msg.ord_rej_reason = None;
1615        msg.text = None;
1616        msg.cum_qty = 0;
1617
1618        let mut cache = AHashMap::new();
1619        let instrument = create_test_perpetual_instrument();
1620        let report = parse_order_msg(&msg, &instrument, &mut cache, UnixNanos::default()).unwrap();
1621
1622        assert_eq!(report.order_status, OrderStatus::Rejected);
1623        assert_eq!(report.cancel_reason, None);
1624    }
1625
1626    #[rstest]
1627    fn test_parse_execution_msg() {
1628        let json_data = load_test_json("ws_execution.json");
1629        let msg: BitmexExecutionMsg = serde_json::from_str(&json_data).unwrap();
1630        let instrument = create_test_perpetual_instrument();
1631        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1632
1633        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1634        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1635        assert_eq!(
1636            fill.venue_order_id.to_string(),
1637            "550e8400-e29b-41d4-a716-446655440002"
1638        );
1639        assert_eq!(
1640            fill.trade_id.to_string(),
1641            "00000000-006d-1000-0000-000e8737d540"
1642        );
1643        assert_eq!(
1644            fill.client_order_id.unwrap().to_string(),
1645            "mm_bitmex_2b/oemUeQ4CAJZgP3fjHsB"
1646        );
1647        assert_eq!(fill.order_side, OrderSide::Sell);
1648        assert_eq!(fill.last_qty, Quantity::from(100));
1649        assert_eq!(fill.last_px, Price::from("98950.0"));
1650        assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
1651        assert_eq!(fill.commission, Money::new(0.00075, Currency::from("XBT")));
1652        assert_eq!(fill.commission.currency.code.to_string(), "XBT");
1653        assert_eq!(fill.ts_event, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1654    }
1655
1656    #[rstest]
1657    fn test_parse_execution_msg_non_trade() {
1658        // Test that non-trade executions return None
1659        let mut msg: BitmexExecutionMsg =
1660            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1661        msg.exec_type = Some(BitmexExecType::Settlement);
1662
1663        let instrument = create_test_perpetual_instrument();
1664        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1665        assert!(result.is_none());
1666    }
1667
1668    #[rstest]
1669    fn test_parse_cancel_reject_execution() {
1670        // Test that CancelReject messages can be parsed (even without symbol)
1671        let json = load_test_json("ws_execution_cancel_reject.json");
1672
1673        let msg: BitmexExecutionMsg = serde_json::from_str(&json).unwrap();
1674        assert_eq!(msg.exec_type, Some(BitmexExecType::CancelReject));
1675        assert_eq!(msg.ord_status, Some(BitmexOrderStatus::Rejected));
1676        assert_eq!(msg.symbol, None);
1677
1678        // Should return None since it's not a Trade
1679        let instrument = create_test_perpetual_instrument();
1680        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1681        assert!(result.is_none());
1682    }
1683
1684    #[rstest]
1685    fn test_parse_execution_msg_liquidation() {
1686        // Critical for ADL/hedge tracking
1687        let mut msg: BitmexExecutionMsg =
1688            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1689        msg.exec_type = Some(BitmexExecType::Liquidation);
1690
1691        let instrument = create_test_perpetual_instrument();
1692        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1693
1694        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1695        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1696        assert_eq!(fill.order_side, OrderSide::Sell);
1697        assert_eq!(fill.last_qty, Quantity::from(100));
1698        assert_eq!(fill.last_px, Price::from("98950.0"));
1699    }
1700
1701    #[rstest]
1702    fn test_parse_execution_msg_bankruptcy() {
1703        let mut msg: BitmexExecutionMsg =
1704            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1705        msg.exec_type = Some(BitmexExecType::Bankruptcy);
1706
1707        let instrument = create_test_perpetual_instrument();
1708        let fill = parse_execution_msg(msg, &instrument, UnixNanos::default()).unwrap();
1709
1710        assert_eq!(fill.account_id.to_string(), "BITMEX-1234567");
1711        assert_eq!(fill.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1712        assert_eq!(fill.order_side, OrderSide::Sell);
1713        assert_eq!(fill.last_qty, Quantity::from(100));
1714    }
1715
1716    #[rstest]
1717    fn test_parse_execution_msg_settlement() {
1718        let mut msg: BitmexExecutionMsg =
1719            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1720        msg.exec_type = Some(BitmexExecType::Settlement);
1721
1722        let instrument = create_test_perpetual_instrument();
1723        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1724        assert!(result.is_none());
1725    }
1726
1727    #[rstest]
1728    fn test_parse_execution_msg_trial_fill() {
1729        let mut msg: BitmexExecutionMsg =
1730            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1731        msg.exec_type = Some(BitmexExecType::TrialFill);
1732
1733        let instrument = create_test_perpetual_instrument();
1734        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1735        assert!(result.is_none());
1736    }
1737
1738    #[rstest]
1739    fn test_parse_execution_msg_funding() {
1740        let mut msg: BitmexExecutionMsg =
1741            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1742        msg.exec_type = Some(BitmexExecType::Funding);
1743
1744        let instrument = create_test_perpetual_instrument();
1745        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1746        assert!(result.is_none());
1747    }
1748
1749    #[rstest]
1750    fn test_parse_execution_msg_insurance() {
1751        let mut msg: BitmexExecutionMsg =
1752            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1753        msg.exec_type = Some(BitmexExecType::Insurance);
1754
1755        let instrument = create_test_perpetual_instrument();
1756        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1757        assert!(result.is_none());
1758    }
1759
1760    #[rstest]
1761    fn test_parse_execution_msg_rebalance() {
1762        let mut msg: BitmexExecutionMsg =
1763            serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1764        msg.exec_type = Some(BitmexExecType::Rebalance);
1765
1766        let instrument = create_test_perpetual_instrument();
1767        let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1768        assert!(result.is_none());
1769    }
1770
1771    #[rstest]
1772    fn test_parse_execution_msg_order_state_changes() {
1773        let instrument = create_test_perpetual_instrument();
1774
1775        let order_state_types = vec![
1776            BitmexExecType::New,
1777            BitmexExecType::Canceled,
1778            BitmexExecType::CancelReject,
1779            BitmexExecType::Replaced,
1780            BitmexExecType::Rejected,
1781            BitmexExecType::AmendReject,
1782            BitmexExecType::Suspended,
1783            BitmexExecType::Released,
1784            BitmexExecType::TriggeredOrActivatedBySystem,
1785        ];
1786
1787        for exec_type in order_state_types {
1788            let mut msg: BitmexExecutionMsg =
1789                serde_json::from_str(&load_test_json("ws_execution.json")).unwrap();
1790            msg.exec_type = Some(exec_type.clone());
1791
1792            let result = parse_execution_msg(msg, &instrument, UnixNanos::default());
1793            assert!(
1794                result.is_none(),
1795                "Expected None for exec_type {exec_type:?}"
1796            );
1797        }
1798    }
1799
1800    #[rstest]
1801    fn test_parse_position_msg() {
1802        let json_data = load_test_json("ws_position.json");
1803        let msg: BitmexPositionMsg = serde_json::from_str(&json_data).unwrap();
1804        let instrument = create_test_perpetual_instrument();
1805        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1806
1807        assert_eq!(report.account_id.to_string(), "BITMEX-1234567");
1808        assert_eq!(report.instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1809        assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
1810        assert_eq!(report.quantity, Quantity::from(1000));
1811        assert!(report.venue_position_id.is_none());
1812        assert_eq!(report.ts_last, 1732530900789000000); // 2024-11-25T10:35:00.789Z
1813    }
1814
1815    #[rstest]
1816    fn test_parse_position_msg_short() {
1817        let mut msg: BitmexPositionMsg =
1818            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1819        msg.current_qty = Some(-500);
1820
1821        let instrument = create_test_perpetual_instrument();
1822        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1823        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1824        assert_eq!(report.quantity, Quantity::from(500));
1825    }
1826
1827    #[rstest]
1828    fn test_parse_position_msg_flat() {
1829        let mut msg: BitmexPositionMsg =
1830            serde_json::from_str(&load_test_json("ws_position.json")).unwrap();
1831        msg.current_qty = Some(0);
1832
1833        let instrument = create_test_perpetual_instrument();
1834        let report = parse_position_msg(&msg, &instrument, UnixNanos::default());
1835        assert_eq!(report.position_side.as_position_side(), PositionSide::Flat);
1836        assert_eq!(report.quantity, Quantity::from(0));
1837    }
1838
1839    #[rstest]
1840    fn test_parse_wallet_msg() {
1841        let json_data = load_test_json("ws_wallet.json");
1842        let msg: BitmexWalletMsg = serde_json::from_str(&json_data).unwrap();
1843        let ts_init = UnixNanos::from(1);
1844        let account_state = parse_wallet_msg(&msg, ts_init);
1845
1846        assert_eq!(account_state.account_id.to_string(), "BITMEX-1234567");
1847        assert!(!account_state.balances.is_empty());
1848        let balance = &account_state.balances[0];
1849        assert_eq!(balance.currency.code.to_string(), "XBT");
1850        // Amount should be converted from satoshis (100005180 / 100_000_000.0 = 1.0000518)
1851        assert!((balance.total.as_f64() - 1.0000518).abs() < 1e-7);
1852        // Wallet messages do not carry locked margin; full amount is free.
1853        assert_eq!(balance.locked.as_f64(), 0.0);
1854        assert_eq!(balance.free.as_decimal(), balance.total.as_decimal());
1855    }
1856
1857    #[rstest]
1858    fn test_parse_wallet_msg_no_amount() {
1859        let mut msg: BitmexWalletMsg =
1860            serde_json::from_str(&load_test_json("ws_wallet.json")).unwrap();
1861        msg.amount = None;
1862
1863        let ts_init = UnixNanos::from(1);
1864        let account_state = parse_wallet_msg(&msg, ts_init);
1865        let balance = &account_state.balances[0];
1866        assert_eq!(balance.total.as_f64(), 0.0);
1867    }
1868
1869    #[rstest]
1870    fn test_parse_margin_msg() {
1871        let json_data = load_test_json("ws_margin.json");
1872        let msg: BitmexMarginMsg = serde_json::from_str(&json_data).unwrap();
1873        let margin_balance = parse_margin_msg(&msg);
1874
1875        assert_eq!(margin_balance.currency.code.to_string(), "XBT");
1876        assert!(margin_balance.instrument_id.is_none());
1877        // Values should be converted from satoshis to BTC
1878        // initMargin is 0 in test data, so should be 0.0
1879        assert_eq!(margin_balance.initial.as_f64(), 0.0);
1880        // maintMargin is 15949 satoshis = 0.00015949 BTC
1881        assert!((margin_balance.maintenance.as_f64() - 0.00015949).abs() < 1e-8);
1882    }
1883
1884    #[rstest]
1885    fn test_parse_margin_msg_no_available() {
1886        let mut msg: BitmexMarginMsg =
1887            serde_json::from_str(&load_test_json("ws_margin.json")).unwrap();
1888        msg.available_margin = None;
1889
1890        let margin_balance = parse_margin_msg(&msg);
1891        // Should still have valid margin values even if available_margin is None
1892        assert!(margin_balance.initial.as_f64() >= 0.0);
1893        assert!(margin_balance.maintenance.as_f64() >= 0.0);
1894    }
1895
1896    #[rstest]
1897    fn test_parse_margin_account_state_includes_margins() {
1898        let msg = BitmexMarginMsg {
1899            account: 123456,
1900            currency: Ustr::from("USDt"),
1901            risk_limit: None,
1902            amount: Some(5_000_000_000),
1903            prev_realised_pnl: None,
1904            gross_comm: None,
1905            gross_open_cost: None,
1906            gross_open_premium: None,
1907            gross_exec_cost: None,
1908            gross_mark_value: None,
1909            risk_value: None,
1910            init_margin: Some(200_000_000),  // 200 USDT
1911            maint_margin: Some(100_000_000), // 100 USDT
1912            target_excess_margin: None,
1913            realised_pnl: None,
1914            unrealised_pnl: None,
1915            wallet_balance: Some(5_000_000_000), // 5000 USDT
1916            margin_balance: None,
1917            margin_leverage: None,
1918            margin_used_pcnt: None,
1919            excess_margin: None,
1920            available_margin: Some(4_800_000_000), // 4800 USDT
1921            withdrawable_margin: None,
1922            maker_fee_discount: None,
1923            taker_fee_discount: None,
1924            timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
1925            foreign_margin_balance: None,
1926            foreign_requirement: None,
1927        };
1928
1929        let ts_init = UnixNanos::from(1_000_000_000u64);
1930        let state = parse_margin_account_state(&msg, ts_init);
1931
1932        assert_eq!(state.account_id.to_string(), "BITMEX-123456");
1933        assert_eq!(state.account_type, AccountType::Margin);
1934        assert_eq!(state.balances.len(), 1);
1935        assert_eq!(state.margins.len(), 1);
1936
1937        let balance = &state.balances[0];
1938        assert_eq!(balance.total.as_f64(), 5000.0);
1939
1940        let margin = &state.margins[0];
1941        assert!(margin.instrument_id.is_none());
1942        assert_eq!(margin.currency.code.as_str(), "USDT");
1943        assert_eq!(margin.initial.as_f64(), 200.0);
1944        assert_eq!(margin.maintenance.as_f64(), 100.0);
1945    }
1946
1947    #[rstest]
1948    fn test_parse_margin_account_state_zero_margins_excluded() {
1949        let msg = BitmexMarginMsg {
1950            account: 123456,
1951            currency: Ustr::from("XBt"),
1952            risk_limit: None,
1953            amount: Some(100_000_000),
1954            prev_realised_pnl: None,
1955            gross_comm: None,
1956            gross_open_cost: None,
1957            gross_open_premium: None,
1958            gross_exec_cost: None,
1959            gross_mark_value: None,
1960            risk_value: None,
1961            init_margin: Some(0),
1962            maint_margin: Some(0),
1963            target_excess_margin: None,
1964            realised_pnl: None,
1965            unrealised_pnl: None,
1966            wallet_balance: Some(100_000_000),
1967            margin_balance: None,
1968            margin_leverage: None,
1969            margin_used_pcnt: None,
1970            excess_margin: None,
1971            available_margin: Some(100_000_000),
1972            withdrawable_margin: None,
1973            maker_fee_discount: None,
1974            taker_fee_discount: None,
1975            timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
1976            foreign_margin_balance: None,
1977            foreign_requirement: None,
1978        };
1979
1980        let state = parse_margin_account_state(&msg, UnixNanos::from(1_000_000_000u64));
1981
1982        assert_eq!(state.balances.len(), 1);
1983        assert_eq!(state.margins.len(), 0);
1984    }
1985
1986    #[rstest]
1987    fn test_parse_instrument_msg_both_prices() {
1988        let json_data = load_test_json("ws_instrument.json");
1989        let msg: BitmexInstrumentMsg = serde_json::from_str(&json_data).unwrap();
1990
1991        // Create cache with test instrument
1992        let mut instruments_cache = AHashMap::new();
1993        let test_instrument = create_test_perpetual_instrument();
1994        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
1995
1996        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
1997
1998        // Mark comes from `markPrice` (95125.7); index from `indicativeSettlePrice` (95126.0).
1999        assert_eq!(updates.len(), 2);
2000
2001        match &updates[0] {
2002            Data::MarkPriceUpdate(update) => {
2003                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2004                assert_eq!(update.value.as_f64(), 95125.7);
2005            }
2006            _ => panic!("Expected MarkPriceUpdate at index 0"),
2007        }
2008
2009        match &updates[1] {
2010            Data::IndexPriceUpdate(update) => {
2011                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2012                assert_eq!(update.value.as_f64(), 95126.0);
2013            }
2014            _ => panic!("Expected IndexPriceUpdate at index 1"),
2015        }
2016    }
2017
2018    #[rstest]
2019    fn test_parse_instrument_msg_mark_price_only() {
2020        let mut msg: BitmexInstrumentMsg =
2021            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2022        msg.index_price = None;
2023        msg.indicative_settle_price = None;
2024
2025        let mut instruments_cache = AHashMap::new();
2026        let test_instrument = create_test_perpetual_instrument();
2027        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2028
2029        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2030
2031        assert_eq!(updates.len(), 1);
2032        match &updates[0] {
2033            Data::MarkPriceUpdate(update) => {
2034                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2035                assert_eq!(update.value.as_f64(), 95125.7);
2036            }
2037            _ => panic!("Expected MarkPriceUpdate"),
2038        }
2039    }
2040
2041    #[rstest]
2042    fn test_parse_instrument_msg_index_price_only() {
2043        let mut msg: BitmexInstrumentMsg =
2044            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2045        msg.mark_price = None;
2046        msg.fair_price = None;
2047
2048        let mut instruments_cache = AHashMap::new();
2049        let test_instrument = create_test_perpetual_instrument();
2050        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2051
2052        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2053
2054        assert_eq!(updates.len(), 1);
2055        match &updates[0] {
2056            Data::IndexPriceUpdate(update) => {
2057                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2058                assert_eq!(update.value.as_f64(), 95126.0);
2059            }
2060            _ => panic!("Expected IndexPriceUpdate"),
2061        }
2062    }
2063
2064    #[rstest]
2065    fn test_parse_instrument_msg_no_prices() {
2066        let mut msg: BitmexInstrumentMsg =
2067            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2068        msg.mark_price = None;
2069        msg.fair_price = None;
2070        msg.index_price = None;
2071        msg.indicative_settle_price = None;
2072        msg.last_price = None;
2073
2074        // Create cache with test instrument
2075        let mut instruments_cache = AHashMap::new();
2076        let test_instrument = create_test_perpetual_instrument();
2077        instruments_cache.insert(Ustr::from("XBTUSD"), test_instrument);
2078
2079        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2080        assert_eq!(updates.len(), 0);
2081    }
2082
2083    #[rstest]
2084    fn test_parse_instrument_msg_index_symbol() {
2085        // Test for index symbols like .BXBT where lastPrice is the index price
2086        // and markPrice equals lastPrice
2087        let mut msg: BitmexInstrumentMsg =
2088            serde_json::from_str(&load_test_json("ws_instrument.json")).unwrap();
2089        msg.symbol = Ustr::from(".BXBT");
2090        msg.last_price = Some(119163.05);
2091        msg.mark_price = Some(119163.05); // Index symbols have mark price equal to last price
2092        msg.fair_price = None;
2093        msg.index_price = None;
2094        msg.indicative_settle_price = None;
2095
2096        // Create instruments cache with proper precision for .BXBT
2097        let instrument_id = InstrumentId::from(".BXBT.BITMEX");
2098        let instrument = CryptoPerpetual::new(
2099            instrument_id,
2100            Symbol::from(".BXBT"),
2101            Currency::BTC(),
2102            Currency::USD(),
2103            Currency::USD(),
2104            false, // is_inverse
2105            2,     // price_precision (for 119163.05)
2106            8,     // size_precision
2107            Price::from("0.01"),
2108            Quantity::from("0.00000001"),
2109            None,                 // multiplier
2110            None,                 // lot_size
2111            None,                 // max_quantity
2112            None,                 // min_quantity
2113            None,                 // max_notional
2114            None,                 // min_notional
2115            None,                 // max_price
2116            None,                 // min_price
2117            None,                 // margin_init
2118            None,                 // margin_maint
2119            None,                 // maker_fee
2120            None,                 // taker_fee
2121            None,                 // tick_scheme
2122            None,                 // info
2123            UnixNanos::default(), // ts_event
2124            UnixNanos::default(), // ts_init
2125        );
2126        let mut instruments_cache = AHashMap::new();
2127        instruments_cache.insert(
2128            Ustr::from(".BXBT"),
2129            InstrumentAny::CryptoPerpetual(instrument),
2130        );
2131
2132        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2133
2134        assert_eq!(updates.len(), 2);
2135
2136        // Check mark price update
2137        match &updates[0] {
2138            Data::MarkPriceUpdate(update) => {
2139                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2140                assert_eq!(update.value, Price::from("119163.05"));
2141            }
2142            _ => panic!("Expected MarkPriceUpdate for index symbol"),
2143        }
2144
2145        // Check index price update
2146        match &updates[1] {
2147            Data::IndexPriceUpdate(update) => {
2148                assert_eq!(update.instrument_id.to_string(), ".BXBT.BITMEX");
2149                assert_eq!(update.value, Price::from("119163.05"));
2150                assert_eq!(update.ts_init, UnixNanos::from(1));
2151            }
2152            _ => panic!("Expected IndexPriceUpdate for index symbol"),
2153        }
2154    }
2155
2156    /// Real-wire mark-only update: pins fairPrice preference.
2157    #[rstest]
2158    fn test_parse_instrument_msg_mark_update_wire_shape() {
2159        let msg: BitmexInstrumentMsg =
2160            serde_json::from_str(&load_test_json("ws_instrument_mark_update.json")).unwrap();
2161
2162        let instrument_id = InstrumentId::from("DOTUSDT.BITMEX");
2163        let instrument = CryptoPerpetual::new(
2164            instrument_id,
2165            Symbol::from("DOTUSDT"),
2166            Currency::from_str("DOT").unwrap(),
2167            Currency::USDT(),
2168            Currency::USDT(),
2169            false, // is_inverse
2170            4,     // price_precision (1.2669)
2171            8,     // size_precision
2172            Price::from("0.0001"),
2173            Quantity::from("0.00000001"),
2174            None,
2175            None,
2176            None,
2177            None,
2178            None,
2179            None,
2180            None,
2181            None,
2182            None,
2183            None,
2184            None,
2185            None,
2186            None,
2187            None,
2188            UnixNanos::default(),
2189            UnixNanos::default(),
2190        );
2191        let mut instruments_cache = AHashMap::new();
2192        instruments_cache.insert(
2193            Ustr::from("DOTUSDT"),
2194            InstrumentAny::CryptoPerpetual(instrument),
2195        );
2196
2197        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2198
2199        assert_eq!(updates.len(), 1);
2200        match &updates[0] {
2201            Data::MarkPriceUpdate(update) => {
2202                assert_eq!(update.instrument_id.to_string(), "DOTUSDT.BITMEX");
2203                assert_eq!(update.value, Price::from("1.2669"));
2204            }
2205            _ => panic!("Expected single MarkPriceUpdate for mark-update wire shape"),
2206        }
2207    }
2208
2209    /// Real-wire index-only update: regression for indicativeSettlePrice routing.
2210    #[rstest]
2211    fn test_parse_instrument_msg_index_update_wire_shape() {
2212        let msg: BitmexInstrumentMsg =
2213            serde_json::from_str(&load_test_json("ws_instrument_index_update.json")).unwrap();
2214
2215        let mut instruments_cache = AHashMap::new();
2216        instruments_cache.insert(
2217            Ustr::from("XBTUSD"),
2218            create_test_perpetual_instrument_with_precisions(2, 0),
2219        );
2220
2221        let updates = parse_instrument_msg(&msg, &instruments_cache, UnixNanos::from(1));
2222
2223        assert_eq!(updates.len(), 1);
2224        match &updates[0] {
2225            Data::IndexPriceUpdate(update) => {
2226                assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2227                assert_eq!(update.value, Price::from("75847.62"));
2228            }
2229            _ => panic!("Expected single IndexPriceUpdate for index-update wire shape"),
2230        }
2231    }
2232
2233    #[rstest]
2234    fn test_parse_funding_msg() {
2235        let json_data = load_test_json("ws_funding_rate.json");
2236        let msg: BitmexFundingMsg = serde_json::from_str(&json_data).unwrap();
2237        let update = parse_funding_msg(&msg, UnixNanos::from(1));
2238
2239        assert_eq!(update.instrument_id.to_string(), "XBTUSD.BITMEX");
2240        assert_eq!(update.rate.to_string(), "0.0001");
2241        assert_eq!(update.interval, Some(60 * 8));
2242        assert!(update.next_funding_ns.is_none());
2243        assert_eq!(update.ts_event, UnixNanos::from(1732507200000000000));
2244        assert_eq!(update.ts_init, UnixNanos::from(1));
2245    }
2246}