Skip to main content

nautilus_bitmex/http/
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//! Conversion routines that map BitMEX REST models into Nautilus domain structures.
17
18use std::str::FromStr;
19
20use dashmap::DashMap;
21use nautilus_core::{UnixNanos, time::get_atomic_clock_realtime, uuid::UUID4};
22use nautilus_model::{
23    data::{Bar, BarType, TradeTick},
24    enums::{ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType},
25    identifiers::{AccountId, ClientOrderId, OrderListId, Symbol, TradeId, VenueOrderId},
26    instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
27    reports::{FillReport, OrderStatusReport, PositionStatusReport},
28    types::{Currency, Money, Price, Quantity, fixed::FIXED_PRECISION},
29};
30use rust_decimal::Decimal;
31use ustr::Ustr;
32use uuid::Uuid;
33
34use super::models::{
35    BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTrade, BitmexTradeBin,
36};
37use crate::common::{
38    enums::{
39        BitmexExecInstruction, BitmexExecType, BitmexInstrumentState, BitmexInstrumentType,
40        BitmexOrderType, BitmexPegPriceType,
41    },
42    parse::{
43        clean_reason, convert_contract_quantity, derive_contract_decimal_and_increment,
44        extract_trigger_type, map_bitmex_currency, normalize_trade_bin_prices,
45        normalize_trade_bin_volume, parse_aggressor_side, parse_contracts_quantity,
46        parse_instrument_id, parse_liquidity_side, parse_optional_datetime_to_unix_nanos,
47        parse_position_side, parse_signed_contracts_quantity,
48    },
49};
50
51/// Result of attempting to parse a BitMEX instrument.
52#[derive(Debug)]
53pub enum InstrumentParseResult {
54    /// Successfully parsed into a Nautilus instrument.
55    Ok(Box<InstrumentAny>),
56    /// Instrument type is not yet supported (intentionally skipped).
57    Unsupported {
58        symbol: String,
59        instrument_type: BitmexInstrumentType,
60    },
61    /// Instrument is not tradeable (delisted, settled, unlisted).
62    Inactive {
63        symbol: String,
64        state: BitmexInstrumentState,
65    },
66    /// Failed to parse due to an error.
67    Failed {
68        symbol: String,
69        instrument_type: BitmexInstrumentType,
70        error: String,
71    },
72}
73
74/// Returns the appropriate position multiplier for a BitMEX instrument.
75///
76/// For inverse contracts, BitMEX uses `underlyingToSettleMultiplier` to define contract sizing,
77/// with fallback to `underlyingToPositionMultiplier` for older historical data.
78/// For linear contracts, BitMEX uses `underlyingToPositionMultiplier`.
79fn get_position_multiplier(definition: &BitmexInstrument) -> Option<f64> {
80    if definition.is_inverse {
81        definition
82            .underlying_to_settle_multiplier
83            .or(definition.underlying_to_position_multiplier)
84    } else {
85        definition.underlying_to_position_multiplier
86    }
87}
88
89/// Attempts to convert a BitMEX instrument record into a Nautilus instrument by type.
90#[must_use]
91pub fn parse_instrument_any(
92    instrument: &BitmexInstrument,
93    ts_init: UnixNanos,
94) -> InstrumentParseResult {
95    let symbol = instrument.symbol.to_string();
96    let instrument_type = instrument.instrument_type;
97
98    match instrument.state {
99        BitmexInstrumentState::Open | BitmexInstrumentState::Closed => {}
100        state @ (BitmexInstrumentState::Unlisted
101        | BitmexInstrumentState::Settled
102        | BitmexInstrumentState::Delisted) => {
103            return InstrumentParseResult::Inactive { symbol, state };
104        }
105    }
106
107    match instrument.instrument_type {
108        BitmexInstrumentType::Spot => match parse_spot_instrument(instrument, ts_init) {
109            Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
110            Err(e) => InstrumentParseResult::Failed {
111                symbol,
112                instrument_type,
113                error: e.to_string(),
114            },
115        },
116        BitmexInstrumentType::PerpetualContract | BitmexInstrumentType::PerpetualContractFx => {
117            // Handle both crypto and FX perpetuals the same way
118            match parse_perpetual_instrument(instrument, ts_init) {
119                Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
120                Err(e) => InstrumentParseResult::Failed {
121                    symbol,
122                    instrument_type,
123                    error: e.to_string(),
124                },
125            }
126        }
127        BitmexInstrumentType::Futures => match parse_futures_instrument(instrument, ts_init) {
128            Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
129            Err(e) => InstrumentParseResult::Failed {
130                symbol,
131                instrument_type,
132                error: e.to_string(),
133            },
134        },
135        BitmexInstrumentType::PredictionMarket => {
136            // Prediction markets work similarly to futures (bounded 0-100, cash settled)
137            match parse_futures_instrument(instrument, ts_init) {
138                Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
139                Err(e) => InstrumentParseResult::Failed {
140                    symbol,
141                    instrument_type,
142                    error: e.to_string(),
143                },
144            }
145        }
146        BitmexInstrumentType::BasketIndex
147        | BitmexInstrumentType::CryptoIndex
148        | BitmexInstrumentType::FxIndex
149        | BitmexInstrumentType::LendingIndex
150        | BitmexInstrumentType::VolatilityIndex
151        | BitmexInstrumentType::StockIndex
152        | BitmexInstrumentType::YieldIndex => {
153            // Parse index instruments as perpetuals for cache purposes
154            // They need to be in cache for WebSocket price updates
155            match parse_index_instrument(instrument, ts_init) {
156                Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
157                Err(e) => InstrumentParseResult::Failed {
158                    symbol,
159                    instrument_type,
160                    error: e.to_string(),
161                },
162            }
163        }
164
165        // Explicitly list unsupported types for clarity
166        BitmexInstrumentType::StockPerpetual
167        | BitmexInstrumentType::CallOption
168        | BitmexInstrumentType::PutOption
169        | BitmexInstrumentType::SwapRate
170        | BitmexInstrumentType::ReferenceBasket
171        | BitmexInstrumentType::LegacyFutures
172        | BitmexInstrumentType::LegacyFuturesN
173        | BitmexInstrumentType::FuturesSpreads => InstrumentParseResult::Unsupported {
174            symbol,
175            instrument_type,
176        },
177    }
178}
179
180/// Parse a BitMEX index instrument into a Nautilus `InstrumentAny`.
181///
182/// Index instruments are parsed as perpetuals with minimal fields to support
183/// price update lookups in the WebSocket.
184///
185/// # Errors
186///
187/// Returns an error if values are out of valid range or cannot be parsed.
188pub fn parse_index_instrument(
189    definition: &BitmexInstrument,
190    ts_init: UnixNanos,
191) -> anyhow::Result<InstrumentAny> {
192    let instrument_id = parse_instrument_id(definition.symbol);
193    let raw_symbol = Symbol::new(definition.symbol);
194
195    let base_currency = Currency::USD();
196    let quote_currency = Currency::USD();
197    let settlement_currency = Currency::USD();
198
199    let price_increment = Price::from(definition.tick_size.to_string());
200    let size_increment = Quantity::from(1); // Indices don't have tradeable sizes
201
202    Ok(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
203        instrument_id,
204        raw_symbol,
205        base_currency,
206        quote_currency,
207        settlement_currency,
208        false, // is_inverse
209        price_increment.precision,
210        size_increment.precision,
211        price_increment,
212        size_increment,
213        None, // multiplier
214        None, // lot_size
215        None, // max_quantity
216        None, // min_quantity
217        None, // max_notional
218        None, // min_notional
219        None, // max_price
220        None, // min_price
221        None, // margin_init
222        None, // margin_maint
223        None, // maker_fee
224        None, // taker_fee
225        ts_init,
226        ts_init,
227    )))
228}
229
230/// Parse a BitMEX spot instrument into a Nautilus `InstrumentAny`.
231///
232/// # Errors
233///
234/// Returns an error if values are out of valid range or cannot be parsed.
235pub fn parse_spot_instrument(
236    definition: &BitmexInstrument,
237    ts_init: UnixNanos,
238) -> anyhow::Result<InstrumentAny> {
239    let instrument_id = parse_instrument_id(definition.symbol);
240    let raw_symbol = Symbol::new(definition.symbol);
241    let base_currency = get_currency(&definition.underlying.to_uppercase());
242    let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
243
244    let price_increment = Price::from(definition.tick_size.to_string());
245
246    let max_scale = FIXED_PRECISION as u32;
247    let (contract_decimal, size_increment) =
248        derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
249
250    let min_quantity = convert_contract_quantity(
251        definition.lot_size,
252        contract_decimal,
253        max_scale,
254        "minimum quantity",
255    )?;
256
257    let taker_fee = definition
258        .taker_fee
259        .and_then(|fee| Decimal::try_from(fee).ok())
260        .unwrap_or(Decimal::ZERO);
261    let maker_fee = definition
262        .maker_fee
263        .and_then(|fee| Decimal::try_from(fee).ok())
264        .unwrap_or(Decimal::ZERO);
265
266    let margin_init = definition
267        .init_margin
268        .as_ref()
269        .and_then(|margin| Decimal::try_from(*margin).ok())
270        .unwrap_or(Decimal::ZERO);
271    let margin_maint = definition
272        .maint_margin
273        .as_ref()
274        .and_then(|margin| Decimal::try_from(*margin).ok())
275        .unwrap_or(Decimal::ZERO);
276
277    let lot_size =
278        convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
279    let max_quantity = convert_contract_quantity(
280        definition.max_order_qty,
281        contract_decimal,
282        max_scale,
283        "max quantity",
284    )?;
285    let max_notional: Option<Money> = None;
286    let min_notional: Option<Money> = None;
287    let max_price = definition
288        .max_price
289        .map(|price| Price::from(price.to_string()));
290    let min_price = definition
291        .min_price
292        .map(|price| Price::from(price.to_string()));
293    let ts_event = UnixNanos::from(definition.timestamp);
294
295    let instrument = CurrencyPair::new(
296        instrument_id,
297        raw_symbol,
298        base_currency,
299        quote_currency,
300        price_increment.precision,
301        size_increment.precision,
302        price_increment,
303        size_increment,
304        None, // multiplier
305        lot_size,
306        max_quantity,
307        min_quantity,
308        max_notional,
309        min_notional,
310        max_price,
311        min_price,
312        Some(margin_init),
313        Some(margin_maint),
314        Some(maker_fee),
315        Some(taker_fee),
316        ts_event,
317        ts_init,
318    );
319
320    Ok(InstrumentAny::CurrencyPair(instrument))
321}
322
323/// Parse a BitMEX perpetual instrument into a Nautilus `InstrumentAny`.
324///
325/// # Errors
326///
327/// Returns an error if values are out of valid range or cannot be parsed.
328pub fn parse_perpetual_instrument(
329    definition: &BitmexInstrument,
330    ts_init: UnixNanos,
331) -> anyhow::Result<InstrumentAny> {
332    let instrument_id = parse_instrument_id(definition.symbol);
333    let raw_symbol = Symbol::new(definition.symbol);
334    let base_currency = get_currency(&definition.underlying.to_uppercase());
335    let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
336    let settlement_currency = get_currency(&definition.settl_currency.as_ref().map_or_else(
337        || definition.quote_currency.to_uppercase(),
338        |s| s.to_uppercase(),
339    ));
340    let is_inverse = definition.is_inverse;
341
342    let price_increment = Price::from(definition.tick_size.to_string());
343
344    let max_scale = FIXED_PRECISION as u32;
345    let (contract_decimal, size_increment) =
346        derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
347
348    let lot_size =
349        convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
350
351    let taker_fee = definition
352        .taker_fee
353        .and_then(|fee| Decimal::try_from(fee).ok())
354        .unwrap_or(Decimal::ZERO);
355    let maker_fee = definition
356        .maker_fee
357        .and_then(|fee| Decimal::try_from(fee).ok())
358        .unwrap_or(Decimal::ZERO);
359
360    let margin_init = definition
361        .init_margin
362        .as_ref()
363        .and_then(|margin| Decimal::try_from(*margin).ok())
364        .unwrap_or(Decimal::ZERO);
365    let margin_maint = definition
366        .maint_margin
367        .as_ref()
368        .and_then(|margin| Decimal::try_from(*margin).ok())
369        .unwrap_or(Decimal::ZERO);
370
371    // TODO: How to handle negative multipliers?
372    let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
373    let max_quantity = convert_contract_quantity(
374        definition.max_order_qty,
375        contract_decimal,
376        max_scale,
377        "max quantity",
378    )?;
379    let min_quantity = lot_size;
380    let max_notional: Option<Money> = None;
381    let min_notional: Option<Money> = None;
382    let max_price = definition
383        .max_price
384        .map(|price| Price::from(price.to_string()));
385    let min_price = definition
386        .min_price
387        .map(|price| Price::from(price.to_string()));
388    let ts_event = UnixNanos::from(definition.timestamp);
389
390    let instrument = CryptoPerpetual::new(
391        instrument_id,
392        raw_symbol,
393        base_currency,
394        quote_currency,
395        settlement_currency,
396        is_inverse,
397        price_increment.precision,
398        size_increment.precision,
399        price_increment,
400        size_increment,
401        multiplier,
402        lot_size,
403        max_quantity,
404        min_quantity,
405        max_notional,
406        min_notional,
407        max_price,
408        min_price,
409        Some(margin_init),
410        Some(margin_maint),
411        Some(maker_fee),
412        Some(taker_fee),
413        ts_event,
414        ts_init,
415    );
416
417    Ok(InstrumentAny::CryptoPerpetual(instrument))
418}
419
420/// Parse a BitMEX futures instrument into a Nautilus `InstrumentAny`.
421///
422/// # Errors
423///
424/// Returns an error if values are out of valid range or cannot be parsed.
425pub fn parse_futures_instrument(
426    definition: &BitmexInstrument,
427    ts_init: UnixNanos,
428) -> anyhow::Result<InstrumentAny> {
429    let instrument_id = parse_instrument_id(definition.symbol);
430    let raw_symbol = Symbol::new(definition.symbol);
431    let underlying = get_currency(&definition.underlying.to_uppercase());
432    let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
433    let settlement_currency = get_currency(&definition.settl_currency.as_ref().map_or_else(
434        || definition.quote_currency.to_uppercase(),
435        |s| s.to_uppercase(),
436    ));
437    let is_inverse = definition.is_inverse;
438
439    let ts_event = UnixNanos::from(definition.timestamp);
440    let activation_ns = definition
441        .listing
442        .as_ref()
443        .map_or(ts_event, |dt| UnixNanos::from(*dt));
444    let expiration_ns = parse_optional_datetime_to_unix_nanos(&definition.expiry, "expiry");
445    let price_increment = Price::from(definition.tick_size.to_string());
446
447    let max_scale = FIXED_PRECISION as u32;
448    let (contract_decimal, size_increment) =
449        derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
450
451    let lot_size =
452        convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
453
454    let taker_fee = definition
455        .taker_fee
456        .and_then(|fee| Decimal::try_from(fee).ok())
457        .unwrap_or(Decimal::ZERO);
458    let maker_fee = definition
459        .maker_fee
460        .and_then(|fee| Decimal::try_from(fee).ok())
461        .unwrap_or(Decimal::ZERO);
462
463    let margin_init = definition
464        .init_margin
465        .as_ref()
466        .and_then(|margin| Decimal::try_from(*margin).ok())
467        .unwrap_or(Decimal::ZERO);
468    let margin_maint = definition
469        .maint_margin
470        .as_ref()
471        .and_then(|margin| Decimal::try_from(*margin).ok())
472        .unwrap_or(Decimal::ZERO);
473
474    // TODO: How to handle negative multipliers?
475    let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
476
477    let max_quantity = convert_contract_quantity(
478        definition.max_order_qty,
479        contract_decimal,
480        max_scale,
481        "max quantity",
482    )?;
483    let min_quantity = lot_size;
484    let max_notional: Option<Money> = None;
485    let min_notional: Option<Money> = None;
486    let max_price = definition
487        .max_price
488        .map(|price| Price::from(price.to_string()));
489    let min_price = definition
490        .min_price
491        .map(|price| Price::from(price.to_string()));
492    let instrument = CryptoFuture::new(
493        instrument_id,
494        raw_symbol,
495        underlying,
496        quote_currency,
497        settlement_currency,
498        is_inverse,
499        activation_ns,
500        expiration_ns,
501        price_increment.precision,
502        size_increment.precision,
503        price_increment,
504        size_increment,
505        multiplier,
506        lot_size,
507        max_quantity,
508        min_quantity,
509        max_notional,
510        min_notional,
511        max_price,
512        min_price,
513        Some(margin_init),
514        Some(margin_maint),
515        Some(maker_fee),
516        Some(taker_fee),
517        ts_event,
518        ts_init,
519    );
520
521    Ok(InstrumentAny::CryptoFuture(instrument))
522}
523
524/// Parse a BitMEX trade into a Nautilus `TradeTick`.
525///
526/// # Errors
527///
528/// Currently this function does not return errors as all fields are handled gracefully,
529/// but returns `Result` for future error handling compatibility.
530pub fn parse_trade(
531    trade: BitmexTrade,
532    instrument: &InstrumentAny,
533    ts_init: UnixNanos,
534) -> anyhow::Result<TradeTick> {
535    let instrument_id = parse_instrument_id(trade.symbol);
536    let price = Price::new(trade.price, instrument.price_precision());
537    let size = parse_contracts_quantity(trade.size as u64, instrument);
538    let aggressor_side = parse_aggressor_side(&trade.side);
539    let trade_id = TradeId::new(
540        trade
541            .trd_match_id
542            .map_or_else(|| Uuid::new_v4().to_string(), |uuid| uuid.to_string()),
543    );
544    let ts_event = UnixNanos::from(trade.timestamp);
545
546    Ok(TradeTick::new(
547        instrument_id,
548        price,
549        size,
550        aggressor_side,
551        trade_id,
552        ts_event,
553        ts_init,
554    ))
555}
556
557/// Converts a BitMEX trade-bin record into a Nautilus [`Bar`].
558///
559/// # Errors
560///
561/// Returns an error when required OHLC fields are missing from the payload.
562///
563/// # Panics
564///
565/// Panics if the bar type or price precision cannot be determined for the instrument, which
566/// indicates the instrument cache was not hydrated prior to parsing.
567pub fn parse_trade_bin(
568    bin: BitmexTradeBin,
569    instrument: &InstrumentAny,
570    bar_type: &BarType,
571    ts_init: UnixNanos,
572) -> anyhow::Result<Bar> {
573    let instrument_id = bar_type.instrument_id();
574    let price_precision = instrument.price_precision();
575
576    let open = bin
577        .open
578        .ok_or_else(|| anyhow::anyhow!("Trade bin missing open price for {instrument_id}"))?;
579    let high = bin
580        .high
581        .ok_or_else(|| anyhow::anyhow!("Trade bin missing high price for {instrument_id}"))?;
582    let low = bin
583        .low
584        .ok_or_else(|| anyhow::anyhow!("Trade bin missing low price for {instrument_id}"))?;
585    let close = bin
586        .close
587        .ok_or_else(|| anyhow::anyhow!("Trade bin missing close price for {instrument_id}"))?;
588
589    let open = Price::new(open, price_precision);
590    let high = Price::new(high, price_precision);
591    let low = Price::new(low, price_precision);
592    let close = Price::new(close, price_precision);
593
594    let (open, high, low, close) =
595        normalize_trade_bin_prices(open, high, low, close, &bin.symbol, Some(bar_type));
596
597    let volume_contracts = normalize_trade_bin_volume(bin.volume, &bin.symbol);
598    let volume = parse_contracts_quantity(volume_contracts, instrument);
599    let ts_event = UnixNanos::from(bin.timestamp);
600
601    Ok(Bar::new(
602        *bar_type, open, high, low, close, volume, ts_event, ts_init,
603    ))
604}
605
606/// Parse a BitMEX order into a Nautilus `OrderStatusReport`.
607///
608/// # BitMEX Response Quirks
609///
610/// BitMEX may omit `ord_status` in responses for completed orders. When this occurs,
611/// the parser defensively infers the status from `leaves_qty` and `cum_qty`:
612/// - `leaves_qty=0, cum_qty>0` -> `Filled`
613/// - `leaves_qty=0, cum_qty<=0` -> `Canceled`
614/// - Otherwise -> Returns error (unparsable)
615///
616/// # Errors
617///
618/// Returns an error if:
619/// - Order is missing `ord_status` and status cannot be inferred from quantity fields.
620/// - Order is missing `order_qty` and cannot be reconstructed from `cum_qty` + `leaves_qty`.
621///
622/// # Panics
623///
624/// Panics if:
625/// - Unsupported `ExecInstruction` type is encountered (other than `ParticipateDoNotInitiate` or `ReduceOnly`)
626pub fn parse_order_status_report(
627    order: &BitmexOrder,
628    instrument: &InstrumentAny,
629    order_type_cache: &DashMap<ClientOrderId, OrderType>,
630    ts_init: UnixNanos,
631) -> anyhow::Result<OrderStatusReport> {
632    let instrument_id = instrument.id();
633    let account_id = AccountId::new(format!("BITMEX-{}", order.account));
634    let venue_order_id = VenueOrderId::new(order.order_id.to_string());
635    let order_side: OrderSide = order
636        .side
637        .map_or(OrderSide::NoOrderSide, |side| side.into());
638
639    // BitMEX omits ord_type in some responses (e.g. cancels, fills),
640    // first try cache lookup, then infer from price/stop_px fields.
641    let order_type: OrderType = order.ord_type.map_or_else(
642        || {
643            if let Some(cl_ord_id) = &order.cl_ord_id {
644                let client_order_id = ClientOrderId::new(cl_ord_id);
645                if let Some(cached_type) = order_type_cache.get(&client_order_id) {
646                    log::debug!(
647                        "Using cached ord_type={:?} for order {}",
648                        *cached_type,
649                        order.order_id,
650                    );
651                    return *cached_type;
652                }
653            }
654
655            let inferred = if order.stop_px.is_some() {
656                if order.price.is_some() {
657                    OrderType::StopLimit
658                } else {
659                    OrderType::StopMarket
660                }
661            } else if order.price.is_some() {
662                OrderType::Limit
663            } else {
664                OrderType::Market
665            };
666            log::debug!(
667                "Inferred ord_type={inferred:?} for order {} (price={:?}, stop_px={:?})",
668                order.order_id,
669                order.price,
670                order.stop_px,
671            );
672            inferred
673        },
674        |t| {
675            // Pegged orders with TrailingStopPeg are trailing stop orders
676            if t == BitmexOrderType::Pegged
677                && order.peg_price_type == Some(BitmexPegPriceType::TrailingStopPeg)
678            {
679                if order.price.is_some() {
680                    OrderType::TrailingStopLimit
681                } else {
682                    OrderType::TrailingStopMarket
683                }
684            } else {
685                t.into()
686            }
687        },
688    );
689
690    // BitMEX may not include time_in_force in cancel responses,
691    // for robustness default to GTC if not provided.
692    let time_in_force: TimeInForce = order
693        .time_in_force
694        .and_then(|tif| tif.try_into().ok())
695        .unwrap_or(TimeInForce::Gtc);
696
697    // BitMEX may omit ord_status in responses for completed orders
698    // Defensively infer from leaves_qty, cum_qty, and working_indicator when possible
699    let order_status: OrderStatus = if let Some(status) = order.ord_status.as_ref() {
700        (*status).into()
701    } else {
702        // Infer status from quantity fields and working indicator
703        match (order.leaves_qty, order.cum_qty, order.working_indicator) {
704            (Some(0), Some(cum), _) if cum > 0 => {
705                log::debug!(
706                    "Inferred Filled from missing ordStatus (leaves_qty=0, cum_qty>0): order_id={:?}, client_order_id={:?}, cum_qty={}",
707                    order.order_id,
708                    order.cl_ord_id,
709                    cum,
710                );
711                OrderStatus::Filled
712            }
713            (Some(0), _, _) => {
714                log::debug!(
715                    "Inferred Canceled from missing ordStatus (leaves_qty=0, cum_qty<=0): order_id={:?}, client_order_id={:?}, cum_qty={:?}",
716                    order.order_id,
717                    order.cl_ord_id,
718                    order.cum_qty,
719                );
720                OrderStatus::Canceled
721            }
722            // BitMEX cancel responses may omit all quantity fields but include working_indicator
723            (None, None, Some(false)) => {
724                log::debug!(
725                    "Inferred Canceled from missing ordStatus with working_indicator=false: order_id={:?}, client_order_id={:?}",
726                    order.order_id,
727                    order.cl_ord_id,
728                );
729                OrderStatus::Canceled
730            }
731            _ => {
732                let order_json = serde_json::to_string(order)?;
733                anyhow::bail!(
734                    "Order missing ord_status and cannot infer (order_id={}, client_order_id={:?}, leaves_qty={:?}, cum_qty={:?}, working_indicator={:?}, order_json={})",
735                    order.order_id,
736                    order.cl_ord_id,
737                    order.leaves_qty,
738                    order.cum_qty,
739                    order.working_indicator,
740                    order_json
741                );
742            }
743        }
744    };
745
746    // Try to get order_qty, or reconstruct from cum_qty + leaves_qty
747    let (quantity, filled_qty) = if let Some(qty) = order.order_qty {
748        let quantity = parse_signed_contracts_quantity(qty, instrument);
749        let filled_qty = parse_signed_contracts_quantity(order.cum_qty.unwrap_or(0), instrument);
750        (quantity, filled_qty)
751    } else if let (Some(cum), Some(leaves)) = (order.cum_qty, order.leaves_qty) {
752        log::debug!(
753            "Reconstructing order_qty from cum_qty + leaves_qty: order_id={:?}, client_order_id={:?}, cum_qty={}, leaves_qty={}",
754            order.order_id,
755            order.cl_ord_id,
756            cum,
757            leaves,
758        );
759        let quantity = parse_signed_contracts_quantity(cum + leaves, instrument);
760        let filled_qty = parse_signed_contracts_quantity(cum, instrument);
761        (quantity, filled_qty)
762    } else if order_status == OrderStatus::Canceled || order_status == OrderStatus::Rejected {
763        // For canceled/rejected orders, both quantities will be reconciled from cache
764        // BitMEX sometimes omits all quantity fields in cancel responses
765        log::debug!(
766            "Order missing quantity fields, using 0 for both (will be reconciled from cache): order_id={:?}, client_order_id={:?}, status={:?}",
767            order.order_id,
768            order.cl_ord_id,
769            order_status,
770        );
771        let zero_qty = Quantity::zero(instrument.size_precision());
772        (zero_qty, zero_qty)
773    } else {
774        anyhow::bail!(
775            "Order missing order_qty and cannot reconstruct (order_id={}, cum_qty={:?}, leaves_qty={:?})",
776            order.order_id,
777            order.cum_qty,
778            order.leaves_qty
779        );
780    };
781    let report_id = UUID4::new();
782    let ts_accepted = order.transact_time.map_or_else(
783        || get_atomic_clock_realtime().get_time_ns(),
784        UnixNanos::from,
785    );
786    let ts_last = order.timestamp.map_or_else(
787        || get_atomic_clock_realtime().get_time_ns(),
788        UnixNanos::from,
789    );
790
791    let mut report = OrderStatusReport::new(
792        account_id,
793        instrument_id,
794        None, // client_order_id - will be set later if present
795        venue_order_id,
796        order_side,
797        order_type,
798        time_in_force,
799        order_status,
800        quantity,
801        filled_qty,
802        ts_accepted,
803        ts_last,
804        ts_init,
805        Some(report_id),
806    );
807
808    if let Some(cl_ord_id) = order.cl_ord_id {
809        report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
810    }
811
812    if let Some(cl_ord_link_id) = order.cl_ord_link_id {
813        report = report.with_order_list_id(OrderListId::new(cl_ord_link_id));
814    }
815
816    let price_precision = instrument.price_precision();
817
818    if let Some(price) = order.price {
819        report = report.with_price(Price::new(price, price_precision));
820    }
821
822    if let Some(avg_px) = order.avg_px {
823        report = report.with_avg_px(avg_px)?;
824    }
825
826    if let Some(trigger_price) = order.stop_px {
827        report = report
828            .with_trigger_price(Price::new(trigger_price, price_precision))
829            .with_trigger_type(extract_trigger_type(order.exec_inst.as_ref()));
830    }
831
832    // Populate trailing offset for trailing stop orders
833    if matches!(
834        order_type,
835        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
836    ) && let Some(peg_offset) = order.peg_offset_value
837    {
838        let trailing_offset = Decimal::try_from(peg_offset.abs())
839            .unwrap_or_else(|_| Decimal::new(peg_offset.abs() as i64, 0));
840        report = report
841            .with_trailing_offset(trailing_offset)
842            .with_trailing_offset_type(TrailingOffsetType::Price);
843
844        if order.stop_px.is_none() {
845            report = report.with_trigger_type(extract_trigger_type(order.exec_inst.as_ref()));
846        }
847    }
848
849    if let Some(exec_instructions) = &order.exec_inst {
850        for inst in exec_instructions {
851            match inst {
852                BitmexExecInstruction::ParticipateDoNotInitiate => {
853                    report = report.with_post_only(true);
854                }
855                BitmexExecInstruction::ReduceOnly => report = report.with_reduce_only(true),
856                BitmexExecInstruction::LastPrice
857                | BitmexExecInstruction::Close
858                | BitmexExecInstruction::MarkPrice
859                | BitmexExecInstruction::IndexPrice
860                | BitmexExecInstruction::AllOrNone
861                | BitmexExecInstruction::Fixed
862                | BitmexExecInstruction::Unknown => {}
863            }
864        }
865    }
866
867    if let Some(contingency_type) = order.contingency_type {
868        report = report.with_contingency_type(contingency_type.into());
869    }
870
871    if matches!(
872        report.contingency_type,
873        ContingencyType::Oco | ContingencyType::Oto | ContingencyType::Ouo
874    ) && report.order_list_id.is_none()
875    {
876        log::debug!(
877            "BitMEX order missing clOrdLinkID for contingent order: order_id={}, client_order_id={:?}, contingency_type={:?}",
878            order.order_id,
879            report.client_order_id,
880            report.contingency_type,
881        );
882    }
883
884    // Extract rejection/cancellation reason
885    if order_status == OrderStatus::Rejected {
886        if let Some(reason) = order.ord_rej_reason.or(order.text) {
887            log::debug!(
888                "Order rejected with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
889                order.order_id,
890                order.cl_ord_id,
891                reason,
892            );
893            report = report.with_cancel_reason(clean_reason(reason.as_ref()));
894        } else {
895            log::debug!(
896                "Order rejected without reason from BitMEX: order_id={:?}, client_order_id={:?}, ord_status={:?}, ord_rej_reason={:?}, text={:?}",
897                order.order_id,
898                order.cl_ord_id,
899                order.ord_status,
900                order.ord_rej_reason,
901                order.text,
902            );
903        }
904    } else if order_status == OrderStatus::Canceled
905        && let Some(reason) = order.ord_rej_reason.or(order.text)
906    {
907        log::trace!(
908            "Order canceled with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
909            order.order_id,
910            order.cl_ord_id,
911            reason,
912        );
913        report = report.with_cancel_reason(clean_reason(reason.as_ref()));
914    }
915
916    // BitMEX does not currently include an explicit expiry timestamp
917    // in the order status response, so `report.expire_time` remains `None`.
918    Ok(report)
919}
920
921/// Parse a BitMEX execution into a Nautilus `FillReport`.
922///
923/// # Errors
924///
925/// Currently this function does not return errors as all fields are handled gracefully,
926/// but returns `Result` for future error handling compatibility.
927///
928/// Parse a BitMEX execution into a Nautilus `FillReport` using instrument scaling.
929///
930/// # Panics
931///
932/// Panics if:
933/// - Execution is missing required fields: `symbol`, `order_id`, `trd_match_id`, `last_qty`, `last_px`, or `transact_time`
934///
935/// # Errors
936///
937/// Returns an error when the execution does not represent a trade or lacks required identifiers.
938pub fn parse_fill_report(
939    exec: BitmexExecution,
940    instrument: &InstrumentAny,
941    ts_init: UnixNanos,
942) -> anyhow::Result<FillReport> {
943    // Skip non-trade executions (funding, settlements, etc.)
944    // Trade executions have exec_type of Trade and must have order_id
945    if !matches!(exec.exec_type, BitmexExecType::Trade) {
946        anyhow::bail!("Skipping non-trade execution: {:?}", exec.exec_type);
947    }
948
949    // Additional check: skip executions without order_id (likely funding/settlement)
950    let order_id = exec.order_id.ok_or_else(|| {
951        anyhow::anyhow!("Skipping execution without order_id: {:?}", exec.exec_type)
952    })?;
953
954    let account_id = AccountId::new(format!("BITMEX-{}", exec.account));
955    let instrument_id = instrument.id();
956    let venue_order_id = VenueOrderId::new(order_id.to_string());
957    // trd_match_id might be missing for some execution types, use exec_id as fallback
958    let trade_id = TradeId::new(
959        exec.trd_match_id
960            .or(Some(exec.exec_id))
961            .ok_or_else(|| anyhow::anyhow!("Fill missing both trd_match_id and exec_id"))?
962            .to_string(),
963    );
964    // Skip executions without side (likely not trades)
965    let Some(side) = exec.side else {
966        anyhow::bail!("Skipping execution without side: {:?}", exec.exec_type);
967    };
968    let order_side: OrderSide = side.into();
969    let last_qty = parse_signed_contracts_quantity(exec.last_qty, instrument);
970    let last_px = Price::new(exec.last_px, instrument.price_precision());
971
972    // Map BitMEX currency to standard currency code
973    let settlement_currency_str = exec.settl_currency.unwrap_or(Ustr::from("XBT")).as_str();
974    let mapped_currency = map_bitmex_currency(settlement_currency_str);
975    let currency = get_currency(&mapped_currency);
976    let commission = Money::new(exec.commission.unwrap_or(0.0), currency);
977    let liquidity_side = parse_liquidity_side(&exec.last_liquidity_ind);
978    let client_order_id = exec.cl_ord_id.map(ClientOrderId::new);
979    let venue_position_id = None; // Not applicable on BitMEX
980    let ts_event = exec.transact_time.map_or_else(
981        || get_atomic_clock_realtime().get_time_ns(),
982        UnixNanos::from,
983    );
984
985    Ok(FillReport::new(
986        account_id,
987        instrument_id,
988        venue_order_id,
989        trade_id,
990        order_side,
991        last_qty,
992        last_px,
993        commission,
994        liquidity_side,
995        client_order_id,
996        venue_position_id,
997        ts_event,
998        ts_init,
999        None,
1000    ))
1001}
1002
1003/// Parse a BitMEX position into a Nautilus `PositionStatusReport`.
1004///
1005/// # Errors
1006///
1007/// Currently this function does not return errors as all fields are handled gracefully,
1008/// but returns `Result` for future error handling compatibility.
1009pub fn parse_position_report(
1010    position: BitmexPosition,
1011    instrument: &InstrumentAny,
1012    ts_init: UnixNanos,
1013) -> anyhow::Result<PositionStatusReport> {
1014    let account_id = AccountId::new(format!("BITMEX-{}", position.account));
1015    let instrument_id = instrument.id();
1016    let position_side = parse_position_side(position.current_qty).as_specified();
1017    let quantity = parse_signed_contracts_quantity(position.current_qty.unwrap_or(0), instrument);
1018    let venue_position_id = None; // Not applicable on BitMEX
1019    let avg_px_open = position
1020        .avg_entry_price
1021        .and_then(|p| Decimal::from_str(&p.to_string()).ok());
1022    let ts_last = parse_optional_datetime_to_unix_nanos(&position.timestamp, "timestamp");
1023
1024    Ok(PositionStatusReport::new(
1025        account_id,
1026        instrument_id,
1027        position_side,
1028        quantity,
1029        ts_last,
1030        ts_init,
1031        None,              // report_id
1032        venue_position_id, // venue_position_id
1033        avg_px_open,       // avg_px_open
1034    ))
1035}
1036
1037/// Returns a currency from the internal map or creates a new crypto currency.
1038///
1039/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
1040/// which automatically registers newly listed BitMEX assets.
1041pub fn get_currency(code: &str) -> Currency {
1042    Currency::get_or_create_crypto(code)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use std::str::FromStr;
1048
1049    use chrono::{DateTime, Utc};
1050    use nautilus_model::{
1051        data::{BarSpecification, BarType},
1052        enums::{AggregationSource, BarAggregation, LiquiditySide, PositionSide, PriceType},
1053        instruments::InstrumentAny,
1054    };
1055    use rstest::rstest;
1056    use rust_decimal::{Decimal, prelude::ToPrimitive};
1057    use uuid::Uuid;
1058
1059    use super::*;
1060    use crate::{
1061        common::{
1062            enums::{
1063                BitmexContingencyType, BitmexFairMethod, BitmexInstrumentState,
1064                BitmexInstrumentType, BitmexLiquidityIndicator, BitmexMarkMethod,
1065                BitmexOrderStatus, BitmexOrderType, BitmexSide, BitmexTickDirection,
1066                BitmexTimeInForce,
1067            },
1068            testing::load_test_json,
1069        },
1070        http::models::{
1071            BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTradeBin,
1072            BitmexWallet,
1073        },
1074    };
1075
1076    #[rstest]
1077    fn test_perp_instrument_deserialization() {
1078        let json_data = load_test_json("http_get_instrument_xbtusd.json");
1079        let instrument: BitmexInstrument = serde_json::from_str(&json_data).unwrap();
1080
1081        assert_eq!(instrument.symbol, "XBTUSD");
1082        assert_eq!(instrument.root_symbol, "XBT");
1083        assert_eq!(instrument.state, BitmexInstrumentState::Open);
1084        assert!(instrument.is_inverse);
1085        assert_eq!(instrument.maker_fee, Some(0.0005));
1086        assert_eq!(
1087            instrument.timestamp.to_rfc3339(),
1088            "2024-11-24T23:33:19.034+00:00"
1089        );
1090    }
1091
1092    #[rstest]
1093    fn test_parse_orders() {
1094        let json_data = load_test_json("http_get_orders.json");
1095        let orders: Vec<BitmexOrder> = serde_json::from_str(&json_data).unwrap();
1096
1097        assert_eq!(orders.len(), 2);
1098
1099        // Test first order (New)
1100        let order1 = &orders[0];
1101        assert_eq!(order1.symbol, Some(Ustr::from("XBTUSD")));
1102        assert_eq!(order1.side, Some(BitmexSide::Buy));
1103        assert_eq!(order1.order_qty, Some(100));
1104        assert_eq!(order1.price, Some(98000.0));
1105        assert_eq!(order1.ord_status, Some(BitmexOrderStatus::New));
1106        assert_eq!(order1.leaves_qty, Some(100));
1107        assert_eq!(order1.cum_qty, Some(0));
1108
1109        // Test second order (Filled)
1110        let order2 = &orders[1];
1111        assert_eq!(order2.symbol, Some(Ustr::from("XBTUSD")));
1112        assert_eq!(order2.side, Some(BitmexSide::Sell));
1113        assert_eq!(order2.order_qty, Some(200));
1114        assert_eq!(order2.ord_status, Some(BitmexOrderStatus::Filled));
1115        assert_eq!(order2.leaves_qty, Some(0));
1116        assert_eq!(order2.cum_qty, Some(200));
1117        assert_eq!(order2.avg_px, Some(98950.5));
1118    }
1119
1120    #[rstest]
1121    fn test_parse_executions() {
1122        let json_data = load_test_json("http_get_executions.json");
1123        let executions: Vec<BitmexExecution> = serde_json::from_str(&json_data).unwrap();
1124
1125        assert_eq!(executions.len(), 2);
1126
1127        // Test first execution (Maker)
1128        let exec1 = &executions[0];
1129        assert_eq!(exec1.symbol, Some(Ustr::from("XBTUSD")));
1130        assert_eq!(exec1.side, Some(BitmexSide::Sell));
1131        assert_eq!(exec1.last_qty, 100);
1132        assert_eq!(exec1.last_px, 98950.0);
1133        assert_eq!(
1134            exec1.last_liquidity_ind,
1135            Some(BitmexLiquidityIndicator::Maker)
1136        );
1137        assert_eq!(exec1.commission, Some(0.00075));
1138
1139        // Test second execution (Taker)
1140        let exec2 = &executions[1];
1141        assert_eq!(
1142            exec2.last_liquidity_ind,
1143            Some(BitmexLiquidityIndicator::Taker)
1144        );
1145        assert_eq!(exec2.last_px, 98951.0);
1146    }
1147
1148    #[rstest]
1149    fn test_parse_positions() {
1150        let json_data = load_test_json("http_get_positions.json");
1151        let positions: Vec<BitmexPosition> = serde_json::from_str(&json_data).unwrap();
1152
1153        assert_eq!(positions.len(), 1);
1154
1155        let position = &positions[0];
1156        assert_eq!(position.account, 1234567);
1157        assert_eq!(position.symbol, "XBTUSD");
1158        assert_eq!(position.current_qty, Some(100));
1159        assert_eq!(position.avg_entry_price, Some(98390.88));
1160        assert_eq!(position.unrealised_pnl, Some(1350));
1161        assert_eq!(position.realised_pnl, Some(-227));
1162        assert_eq!(position.is_open, Some(true));
1163    }
1164
1165    #[rstest]
1166    fn test_parse_trades() {
1167        let json_data = load_test_json("http_get_trades.json");
1168        let trades: Vec<BitmexTrade> = serde_json::from_str(&json_data).unwrap();
1169
1170        assert_eq!(trades.len(), 3);
1171
1172        // Test first trade
1173        let trade1 = &trades[0];
1174        assert_eq!(trade1.symbol, "XBTUSD");
1175        assert_eq!(trade1.side, Some(BitmexSide::Buy));
1176        assert_eq!(trade1.size, 100);
1177        assert_eq!(trade1.price, 98950.0);
1178
1179        // Test third trade (Sell side)
1180        let trade3 = &trades[2];
1181        assert_eq!(trade3.side, Some(BitmexSide::Sell));
1182        assert_eq!(trade3.size, 50);
1183        assert_eq!(trade3.price, 98949.5);
1184    }
1185
1186    #[rstest]
1187    fn test_parse_wallet() {
1188        let json_data = load_test_json("http_get_wallet.json");
1189        let wallets: Vec<BitmexWallet> = serde_json::from_str(&json_data).unwrap();
1190
1191        assert_eq!(wallets.len(), 1);
1192
1193        let wallet = &wallets[0];
1194        assert_eq!(wallet.account, 1234567);
1195        assert_eq!(wallet.currency, "XBt");
1196        assert_eq!(wallet.amount, Some(1000123456));
1197        assert_eq!(wallet.delta_amount, Some(123456));
1198    }
1199
1200    #[rstest]
1201    fn test_parse_trade_bins() {
1202        let json_data = load_test_json("http_get_trade_bins.json");
1203        let bins: Vec<BitmexTradeBin> = serde_json::from_str(&json_data).unwrap();
1204
1205        assert_eq!(bins.len(), 3);
1206
1207        // Test first bin
1208        let bin1 = &bins[0];
1209        assert_eq!(bin1.symbol, "XBTUSD");
1210        assert_eq!(bin1.open, Some(98900.0));
1211        assert_eq!(bin1.high, Some(98980.5));
1212        assert_eq!(bin1.low, Some(98890.0));
1213        assert_eq!(bin1.close, Some(98950.0));
1214        assert_eq!(bin1.volume, Some(150000));
1215        assert_eq!(bin1.trades, Some(45));
1216
1217        // Test last bin
1218        let bin3 = &bins[2];
1219        assert_eq!(bin3.close, Some(98970.0));
1220        assert_eq!(bin3.volume, Some(78000));
1221    }
1222
1223    #[rstest]
1224    fn test_parse_trade_bin_to_bar() {
1225        let json_data = load_test_json("http_get_trade_bins.json");
1226        let bins: Vec<BitmexTradeBin> = serde_json::from_str(&json_data).unwrap();
1227        let instrument_json = load_test_json("http_get_instrument_xbtusd.json");
1228        let instrument: BitmexInstrument = serde_json::from_str(&instrument_json).unwrap();
1229
1230        let ts_init = UnixNanos::from(1u64);
1231        let instrument_any = match parse_instrument_any(&instrument, ts_init) {
1232            InstrumentParseResult::Ok(inst) => inst,
1233            other => panic!("Expected Ok, was {other:?}"),
1234        };
1235
1236        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
1237        let bar_type = BarType::new(instrument_any.id(), spec, AggregationSource::External);
1238
1239        let bar = parse_trade_bin(bins[0].clone(), &instrument_any, &bar_type, ts_init).unwrap();
1240
1241        let precision = instrument_any.price_precision();
1242        let expected_open =
1243            Price::from_decimal_dp(Decimal::from_str("98900.0").unwrap(), precision)
1244                .expect("open price");
1245        let expected_close =
1246            Price::from_decimal_dp(Decimal::from_str("98950.0").unwrap(), precision)
1247                .expect("close price");
1248
1249        assert_eq!(bar.bar_type, bar_type);
1250        assert_eq!(bar.open, expected_open);
1251        assert_eq!(bar.close, expected_close);
1252    }
1253
1254    #[rstest]
1255    fn test_parse_trade_bin_extreme_adjustment() {
1256        let instrument_json = load_test_json("http_get_instrument_xbtusd.json");
1257        let instrument: BitmexInstrument = serde_json::from_str(&instrument_json).unwrap();
1258
1259        let ts_init = UnixNanos::from(1u64);
1260        let instrument_any = match parse_instrument_any(&instrument, ts_init) {
1261            InstrumentParseResult::Ok(inst) => inst,
1262            other => panic!("Expected Ok, was {other:?}"),
1263        };
1264
1265        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
1266        let bar_type = BarType::new(instrument_any.id(), spec, AggregationSource::External);
1267
1268        let bin = BitmexTradeBin {
1269            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1270                .unwrap()
1271                .with_timezone(&Utc),
1272            symbol: Ustr::from("XBTUSD"),
1273            open: Some(50_000.0),
1274            high: Some(49_990.0),
1275            low: Some(50_010.0),
1276            close: Some(50_005.0),
1277            trades: Some(5),
1278            volume: Some(1_000),
1279            vwap: None,
1280            last_size: None,
1281            turnover: None,
1282            home_notional: None,
1283            foreign_notional: None,
1284        };
1285
1286        let bar = parse_trade_bin(bin, &instrument_any, &bar_type, ts_init).unwrap();
1287
1288        let precision = instrument_any.price_precision();
1289        let expected_high =
1290            Price::from_decimal_dp(Decimal::from_str("50010.0").unwrap(), precision)
1291                .expect("high price");
1292        let expected_low = Price::from_decimal_dp(Decimal::from_str("49990.0").unwrap(), precision)
1293            .expect("low price");
1294        let expected_open =
1295            Price::from_decimal_dp(Decimal::from_str("50000.0").unwrap(), precision)
1296                .expect("open price");
1297
1298        assert_eq!(bar.high, expected_high);
1299        assert_eq!(bar.low, expected_low);
1300        assert_eq!(bar.open, expected_open);
1301    }
1302
1303    #[rstest]
1304    fn test_parse_order_status_report() {
1305        let order = BitmexOrder {
1306            account: 123456,
1307            symbol: Some(Ustr::from("XBTUSD")),
1308            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
1309            cl_ord_id: Some(Ustr::from("client-123")),
1310            cl_ord_link_id: None,
1311            side: Some(BitmexSide::Buy),
1312            ord_type: Some(BitmexOrderType::Limit),
1313            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1314            ord_status: Some(BitmexOrderStatus::New),
1315            order_qty: Some(100),
1316            cum_qty: Some(50),
1317            price: Some(50000.0),
1318            stop_px: Some(49000.0),
1319            display_qty: None,
1320            peg_offset_value: None,
1321            peg_price_type: None,
1322            currency: Some(Ustr::from("USD")),
1323            settl_currency: Some(Ustr::from("XBt")),
1324            exec_inst: Some(vec![
1325                BitmexExecInstruction::ParticipateDoNotInitiate,
1326                BitmexExecInstruction::ReduceOnly,
1327            ]),
1328            contingency_type: Some(BitmexContingencyType::OneCancelsTheOther),
1329            ex_destination: None,
1330            triggered: None,
1331            working_indicator: Some(true),
1332            ord_rej_reason: None,
1333            leaves_qty: Some(50),
1334            avg_px: None,
1335            multi_leg_reporting_type: None,
1336            text: None,
1337            transact_time: Some(
1338                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1339                    .unwrap()
1340                    .with_timezone(&Utc),
1341            ),
1342            timestamp: Some(
1343                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1344                    .unwrap()
1345                    .with_timezone(&Utc),
1346            ),
1347        };
1348
1349        let instrument =
1350            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1351                .unwrap();
1352        let report =
1353            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1354                .unwrap();
1355
1356        assert_eq!(report.account_id.to_string(), "BITMEX-123456");
1357        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
1358        assert_eq!(
1359            report.venue_order_id.as_str(),
1360            "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1361        );
1362        assert_eq!(report.client_order_id.unwrap().as_str(), "client-123");
1363        assert_eq!(report.quantity.as_f64(), 100.0);
1364        assert_eq!(report.filled_qty.as_f64(), 50.0);
1365        assert_eq!(report.price.unwrap().as_f64(), 50000.0);
1366        assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1367        assert!(report.post_only);
1368        assert!(report.reduce_only);
1369    }
1370
1371    #[rstest]
1372    fn test_parse_order_status_report_minimal() {
1373        let order = BitmexOrder {
1374            account: 0, // Use 0 for test account
1375            symbol: Some(Ustr::from("ETHUSD")),
1376            order_id: Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap(),
1377            cl_ord_id: None,
1378            cl_ord_link_id: None,
1379            side: Some(BitmexSide::Sell),
1380            ord_type: Some(BitmexOrderType::Market),
1381            time_in_force: Some(BitmexTimeInForce::ImmediateOrCancel),
1382            ord_status: Some(BitmexOrderStatus::Filled),
1383            order_qty: Some(200),
1384            cum_qty: Some(200),
1385            price: None,
1386            stop_px: None,
1387            display_qty: None,
1388            peg_offset_value: None,
1389            peg_price_type: None,
1390            currency: None,
1391            settl_currency: None,
1392            exec_inst: None,
1393            contingency_type: None,
1394            ex_destination: None,
1395            triggered: None,
1396            working_indicator: Some(false),
1397            ord_rej_reason: None,
1398            leaves_qty: Some(0),
1399            avg_px: None,
1400            multi_leg_reporting_type: None,
1401            text: None,
1402            transact_time: Some(
1403                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1404                    .unwrap()
1405                    .with_timezone(&Utc),
1406            ),
1407            timestamp: Some(
1408                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1409                    .unwrap()
1410                    .with_timezone(&Utc),
1411            ),
1412        };
1413
1414        let mut instrument_def = create_test_perpetual_instrument();
1415        instrument_def.symbol = Ustr::from("ETHUSD");
1416        instrument_def.underlying = Ustr::from("ETH");
1417        instrument_def.quote_currency = Ustr::from("USD");
1418        instrument_def.settl_currency = Some(Ustr::from("USDt"));
1419        let instrument = parse_perpetual_instrument(&instrument_def, UnixNanos::default()).unwrap();
1420        let report =
1421            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1422                .unwrap();
1423
1424        assert_eq!(report.account_id.to_string(), "BITMEX-0");
1425        assert_eq!(report.instrument_id.to_string(), "ETHUSD.BITMEX");
1426        assert_eq!(
1427            report.venue_order_id.as_str(),
1428            "11111111-2222-3333-4444-555555555555"
1429        );
1430        assert!(report.client_order_id.is_none());
1431        assert_eq!(report.quantity.as_f64(), 200.0);
1432        assert_eq!(report.filled_qty.as_f64(), 200.0);
1433        assert!(report.price.is_none());
1434        assert!(report.trigger_price.is_none());
1435        assert!(!report.post_only);
1436        assert!(!report.reduce_only);
1437    }
1438
1439    #[rstest]
1440    fn test_parse_order_status_report_missing_order_qty_reconstructed() {
1441        let order = BitmexOrder {
1442            account: 789012,
1443            symbol: Some(Ustr::from("XBTUSD")),
1444            order_id: Uuid::parse_str("aaaabbbb-cccc-dddd-eeee-ffffffffffff").unwrap(),
1445            cl_ord_id: Some(Ustr::from("client-cancel-test")),
1446            cl_ord_link_id: None,
1447            side: Some(BitmexSide::Buy),
1448            ord_type: Some(BitmexOrderType::Limit),
1449            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1450            ord_status: Some(BitmexOrderStatus::Canceled),
1451            order_qty: None,      // Missing - should be reconstructed
1452            cum_qty: Some(75),    // Filled 75
1453            leaves_qty: Some(25), // Remaining 25
1454            price: Some(45000.0),
1455            stop_px: None,
1456            display_qty: None,
1457            peg_offset_value: None,
1458            peg_price_type: None,
1459            currency: Some(Ustr::from("USD")),
1460            settl_currency: Some(Ustr::from("XBt")),
1461            exec_inst: None,
1462            contingency_type: None,
1463            ex_destination: None,
1464            triggered: None,
1465            working_indicator: Some(false),
1466            ord_rej_reason: None,
1467            avg_px: Some(45050.0),
1468            multi_leg_reporting_type: None,
1469            text: None,
1470            transact_time: Some(
1471                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1472                    .unwrap()
1473                    .with_timezone(&Utc),
1474            ),
1475            timestamp: Some(
1476                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1477                    .unwrap()
1478                    .with_timezone(&Utc),
1479            ),
1480        };
1481
1482        let instrument =
1483            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1484                .unwrap();
1485        let report =
1486            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1487                .unwrap();
1488
1489        // Verify order_qty was reconstructed from cum_qty + leaves_qty
1490        assert_eq!(report.quantity.as_f64(), 100.0); // 75 + 25
1491        assert_eq!(report.filled_qty.as_f64(), 75.0);
1492        assert_eq!(report.order_status, OrderStatus::Canceled);
1493    }
1494
1495    #[rstest]
1496    fn test_parse_order_status_report_uses_provided_order_qty() {
1497        let order = BitmexOrder {
1498            account: 123456,
1499            symbol: Some(Ustr::from("XBTUSD")),
1500            order_id: Uuid::parse_str("bbbbcccc-dddd-eeee-ffff-000000000000").unwrap(),
1501            cl_ord_id: Some(Ustr::from("client-provided-qty")),
1502            cl_ord_link_id: None,
1503            side: Some(BitmexSide::Sell),
1504            ord_type: Some(BitmexOrderType::Limit),
1505            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1506            ord_status: Some(BitmexOrderStatus::PartiallyFilled),
1507            order_qty: Some(150),  // Explicitly provided
1508            cum_qty: Some(50),     // Filled 50
1509            leaves_qty: Some(100), // Remaining 100
1510            price: Some(48000.0),
1511            stop_px: None,
1512            display_qty: None,
1513            peg_offset_value: None,
1514            peg_price_type: None,
1515            currency: Some(Ustr::from("USD")),
1516            settl_currency: Some(Ustr::from("XBt")),
1517            exec_inst: None,
1518            contingency_type: None,
1519            ex_destination: None,
1520            triggered: None,
1521            working_indicator: Some(true),
1522            ord_rej_reason: None,
1523            avg_px: Some(48100.0),
1524            multi_leg_reporting_type: None,
1525            text: None,
1526            transact_time: Some(
1527                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1528                    .unwrap()
1529                    .with_timezone(&Utc),
1530            ),
1531            timestamp: Some(
1532                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1533                    .unwrap()
1534                    .with_timezone(&Utc),
1535            ),
1536        };
1537
1538        let instrument =
1539            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1540                .unwrap();
1541        let report =
1542            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1543                .unwrap();
1544
1545        // Verify order_qty was used directly (not reconstructed)
1546        assert_eq!(report.quantity.as_f64(), 150.0);
1547        assert_eq!(report.filled_qty.as_f64(), 50.0);
1548        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1549    }
1550
1551    #[rstest]
1552    fn test_parse_order_status_report_missing_order_qty_fails() {
1553        let order = BitmexOrder {
1554            account: 789012,
1555            symbol: Some(Ustr::from("XBTUSD")),
1556            order_id: Uuid::parse_str("aaaabbbb-cccc-dddd-eeee-ffffffffffff").unwrap(),
1557            cl_ord_id: Some(Ustr::from("client-fail-test")),
1558            cl_ord_link_id: None,
1559            side: Some(BitmexSide::Buy),
1560            ord_type: Some(BitmexOrderType::Limit),
1561            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1562            ord_status: Some(BitmexOrderStatus::PartiallyFilled),
1563            order_qty: None,   // Missing
1564            cum_qty: Some(75), // Present
1565            leaves_qty: None,  // Missing - cannot reconstruct
1566            price: Some(45000.0),
1567            stop_px: None,
1568            display_qty: None,
1569            peg_offset_value: None,
1570            peg_price_type: None,
1571            currency: Some(Ustr::from("USD")),
1572            settl_currency: Some(Ustr::from("XBt")),
1573            exec_inst: None,
1574            contingency_type: None,
1575            ex_destination: None,
1576            triggered: None,
1577            working_indicator: Some(false),
1578            ord_rej_reason: None,
1579            avg_px: None,
1580            multi_leg_reporting_type: None,
1581            text: None,
1582            transact_time: Some(
1583                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1584                    .unwrap()
1585                    .with_timezone(&Utc),
1586            ),
1587            timestamp: Some(
1588                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1589                    .unwrap()
1590                    .with_timezone(&Utc),
1591            ),
1592        };
1593
1594        let instrument =
1595            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1596                .unwrap();
1597
1598        // Should fail because we cannot reconstruct order_qty
1599        let result =
1600            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
1601        assert!(result.is_err());
1602        assert!(
1603            result
1604                .unwrap_err()
1605                .to_string()
1606                .contains("Order missing order_qty and cannot reconstruct")
1607        );
1608    }
1609
1610    #[rstest]
1611    fn test_parse_order_status_report_canceled_missing_all_quantities() {
1612        let order = BitmexOrder {
1613            account: 123456,
1614            symbol: Some(Ustr::from("XBTUSD")),
1615            order_id: Uuid::parse_str("ffff0000-1111-2222-3333-444444444444").unwrap(),
1616            cl_ord_id: Some(Ustr::from("client-cancel-no-qty")),
1617            cl_ord_link_id: None,
1618            side: Some(BitmexSide::Buy),
1619            ord_type: Some(BitmexOrderType::Limit),
1620            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1621            ord_status: Some(BitmexOrderStatus::Canceled),
1622            order_qty: None,  // Missing
1623            cum_qty: None,    // Missing
1624            leaves_qty: None, // Missing
1625            price: Some(50000.0),
1626            stop_px: None,
1627            display_qty: None,
1628            peg_offset_value: None,
1629            peg_price_type: None,
1630            currency: Some(Ustr::from("USD")),
1631            settl_currency: Some(Ustr::from("XBt")),
1632            exec_inst: None,
1633            contingency_type: None,
1634            ex_destination: None,
1635            triggered: None,
1636            working_indicator: Some(false),
1637            ord_rej_reason: None,
1638            avg_px: None,
1639            multi_leg_reporting_type: None,
1640            text: None,
1641            transact_time: Some(
1642                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1643                    .unwrap()
1644                    .with_timezone(&Utc),
1645            ),
1646            timestamp: Some(
1647                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1648                    .unwrap()
1649                    .with_timezone(&Utc),
1650            ),
1651        };
1652
1653        let instrument =
1654            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1655                .unwrap();
1656        let report =
1657            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1658                .unwrap();
1659
1660        // For canceled orders with missing quantities, parser uses 0 (will be reconciled from cache)
1661        assert_eq!(report.order_status, OrderStatus::Canceled);
1662        assert_eq!(report.quantity.as_f64(), 0.0);
1663        assert_eq!(report.filled_qty.as_f64(), 0.0);
1664    }
1665
1666    #[rstest]
1667    fn test_parse_order_status_report_rejected_with_reason() {
1668        let order = BitmexOrder {
1669            account: 123456,
1670            symbol: Some(Ustr::from("XBTUSD")),
1671            order_id: Uuid::parse_str("ccccdddd-eeee-ffff-0000-111111111111").unwrap(),
1672            cl_ord_id: Some(Ustr::from("client-rejected")),
1673            cl_ord_link_id: None,
1674            side: Some(BitmexSide::Buy),
1675            ord_type: Some(BitmexOrderType::Limit),
1676            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1677            ord_status: Some(BitmexOrderStatus::Rejected),
1678            order_qty: Some(100),
1679            cum_qty: Some(0),
1680            leaves_qty: Some(0),
1681            price: Some(50000.0),
1682            stop_px: None,
1683            display_qty: None,
1684            peg_offset_value: None,
1685            peg_price_type: None,
1686            currency: Some(Ustr::from("USD")),
1687            settl_currency: Some(Ustr::from("XBt")),
1688            exec_inst: None,
1689            contingency_type: None,
1690            ex_destination: None,
1691            triggered: None,
1692            working_indicator: Some(false),
1693            ord_rej_reason: Some(Ustr::from("Insufficient margin")),
1694            avg_px: None,
1695            multi_leg_reporting_type: None,
1696            text: None,
1697            transact_time: Some(
1698                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1699                    .unwrap()
1700                    .with_timezone(&Utc),
1701            ),
1702            timestamp: Some(
1703                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1704                    .unwrap()
1705                    .with_timezone(&Utc),
1706            ),
1707        };
1708
1709        let instrument =
1710            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1711                .unwrap();
1712        let report =
1713            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1714                .unwrap();
1715
1716        assert_eq!(report.order_status, OrderStatus::Rejected);
1717        assert_eq!(
1718            report.cancel_reason,
1719            Some("Insufficient margin".to_string())
1720        );
1721    }
1722
1723    #[rstest]
1724    fn test_parse_order_status_report_rejected_with_text_fallback() {
1725        let order = BitmexOrder {
1726            account: 123456,
1727            symbol: Some(Ustr::from("XBTUSD")),
1728            order_id: Uuid::parse_str("ddddeeee-ffff-0000-1111-222222222222").unwrap(),
1729            cl_ord_id: Some(Ustr::from("client-rejected-text")),
1730            cl_ord_link_id: None,
1731            side: Some(BitmexSide::Sell),
1732            ord_type: Some(BitmexOrderType::Limit),
1733            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
1734            ord_status: Some(BitmexOrderStatus::Rejected),
1735            order_qty: Some(100),
1736            cum_qty: Some(0),
1737            leaves_qty: Some(0),
1738            price: Some(50000.0),
1739            stop_px: None,
1740            display_qty: None,
1741            peg_offset_value: None,
1742            peg_price_type: None,
1743            currency: Some(Ustr::from("USD")),
1744            settl_currency: Some(Ustr::from("XBt")),
1745            exec_inst: None,
1746            contingency_type: None,
1747            ex_destination: None,
1748            triggered: None,
1749            working_indicator: Some(false),
1750            ord_rej_reason: None,
1751            avg_px: None,
1752            multi_leg_reporting_type: None,
1753            text: Some(Ustr::from("Order would immediately execute")),
1754            transact_time: Some(
1755                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1756                    .unwrap()
1757                    .with_timezone(&Utc),
1758            ),
1759            timestamp: Some(
1760                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1761                    .unwrap()
1762                    .with_timezone(&Utc),
1763            ),
1764        };
1765
1766        let instrument =
1767            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1768                .unwrap();
1769        let report =
1770            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1771                .unwrap();
1772
1773        assert_eq!(report.order_status, OrderStatus::Rejected);
1774        assert_eq!(
1775            report.cancel_reason,
1776            Some("Order would immediately execute".to_string())
1777        );
1778    }
1779
1780    #[rstest]
1781    fn test_parse_order_status_report_rejected_without_reason() {
1782        let order = BitmexOrder {
1783            account: 123456,
1784            symbol: Some(Ustr::from("XBTUSD")),
1785            order_id: Uuid::parse_str("eeeeffff-0000-1111-2222-333333333333").unwrap(),
1786            cl_ord_id: Some(Ustr::from("client-rejected-no-reason")),
1787            cl_ord_link_id: None,
1788            side: Some(BitmexSide::Buy),
1789            ord_type: Some(BitmexOrderType::Market),
1790            time_in_force: Some(BitmexTimeInForce::ImmediateOrCancel),
1791            ord_status: Some(BitmexOrderStatus::Rejected),
1792            order_qty: Some(50),
1793            cum_qty: Some(0),
1794            leaves_qty: Some(0),
1795            price: None,
1796            stop_px: None,
1797            display_qty: None,
1798            peg_offset_value: None,
1799            peg_price_type: None,
1800            currency: Some(Ustr::from("USD")),
1801            settl_currency: Some(Ustr::from("XBt")),
1802            exec_inst: None,
1803            contingency_type: None,
1804            ex_destination: None,
1805            triggered: None,
1806            working_indicator: Some(false),
1807            ord_rej_reason: None,
1808            avg_px: None,
1809            multi_leg_reporting_type: None,
1810            text: None,
1811            transact_time: Some(
1812                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1813                    .unwrap()
1814                    .with_timezone(&Utc),
1815            ),
1816            timestamp: Some(
1817                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1818                    .unwrap()
1819                    .with_timezone(&Utc),
1820            ),
1821        };
1822
1823        let instrument =
1824            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1825                .unwrap();
1826        let report =
1827            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
1828                .unwrap();
1829
1830        assert_eq!(report.order_status, OrderStatus::Rejected);
1831        assert_eq!(report.cancel_reason, None);
1832    }
1833
1834    #[rstest]
1835    fn test_parse_fill_report() {
1836        let exec = BitmexExecution {
1837            exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
1838            account: 654321,
1839            symbol: Some(Ustr::from("XBTUSD")),
1840            order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
1841            cl_ord_id: Some(Ustr::from("client-456")),
1842            side: Some(BitmexSide::Buy),
1843            last_qty: 50,
1844            last_px: 50100.5,
1845            commission: Some(0.00075),
1846            settl_currency: Some(Ustr::from("XBt")),
1847            last_liquidity_ind: Some(BitmexLiquidityIndicator::Taker),
1848            trd_match_id: Some(Uuid::parse_str("99999999-8888-7777-6666-555555555555").unwrap()),
1849            transact_time: Some(
1850                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1851                    .unwrap()
1852                    .with_timezone(&Utc),
1853            ),
1854            cl_ord_link_id: None,
1855            underlying_last_px: None,
1856            last_mkt: None,
1857            order_qty: Some(50),
1858            price: Some(50100.0),
1859            display_qty: None,
1860            stop_px: None,
1861            peg_offset_value: None,
1862            peg_price_type: None,
1863            currency: None,
1864            exec_type: BitmexExecType::Trade,
1865            ord_type: BitmexOrderType::Limit,
1866            time_in_force: BitmexTimeInForce::GoodTillCancel,
1867            exec_inst: None,
1868            contingency_type: None,
1869            ex_destination: None,
1870            ord_status: Some(BitmexOrderStatus::Filled),
1871            triggered: None,
1872            working_indicator: None,
1873            ord_rej_reason: None,
1874            leaves_qty: None,
1875            cum_qty: Some(50),
1876            avg_px: Some(50100.5),
1877            trade_publish_indicator: None,
1878            multi_leg_reporting_type: None,
1879            text: None,
1880            exec_cost: None,
1881            exec_comm: None,
1882            home_notional: None,
1883            foreign_notional: None,
1884            timestamp: None,
1885        };
1886
1887        let instrument =
1888            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
1889                .unwrap();
1890
1891        let report = parse_fill_report(exec, &instrument, UnixNanos::from(1)).unwrap();
1892
1893        assert_eq!(report.account_id.to_string(), "BITMEX-654321");
1894        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
1895        assert_eq!(
1896            report.venue_order_id.as_str(),
1897            "a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6"
1898        );
1899        assert_eq!(
1900            report.trade_id.to_string(),
1901            "99999999-8888-7777-6666-555555555555"
1902        );
1903        assert_eq!(report.client_order_id.unwrap().as_str(), "client-456");
1904        assert_eq!(report.last_qty.as_f64(), 50.0);
1905        assert_eq!(report.last_px.as_f64(), 50100.5);
1906        assert_eq!(report.commission.as_f64(), 0.00075);
1907        assert_eq!(report.commission.currency.code.as_str(), "XBT");
1908        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1909    }
1910
1911    #[rstest]
1912    fn test_parse_fill_report_with_missing_trd_match_id() {
1913        let exec = BitmexExecution {
1914            exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
1915            account: 111111,
1916            symbol: Some(Ustr::from("ETHUSD")),
1917            order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
1918            cl_ord_id: None,
1919            side: Some(BitmexSide::Sell),
1920            last_qty: 100,
1921            last_px: 3000.0,
1922            commission: None,
1923            settl_currency: None,
1924            last_liquidity_ind: Some(BitmexLiquidityIndicator::Maker),
1925            trd_match_id: None, // Missing, should fall back to exec_id
1926            transact_time: Some(
1927                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1928                    .unwrap()
1929                    .with_timezone(&Utc),
1930            ),
1931            cl_ord_link_id: None,
1932            underlying_last_px: None,
1933            last_mkt: None,
1934            order_qty: Some(100),
1935            price: Some(3000.0),
1936            display_qty: None,
1937            stop_px: None,
1938            peg_offset_value: None,
1939            peg_price_type: None,
1940            currency: None,
1941            exec_type: BitmexExecType::Trade,
1942            ord_type: BitmexOrderType::Market,
1943            time_in_force: BitmexTimeInForce::ImmediateOrCancel,
1944            exec_inst: None,
1945            contingency_type: None,
1946            ex_destination: None,
1947            ord_status: Some(BitmexOrderStatus::Filled),
1948            triggered: None,
1949            working_indicator: None,
1950            ord_rej_reason: None,
1951            leaves_qty: None,
1952            cum_qty: Some(100),
1953            avg_px: Some(3000.0),
1954            trade_publish_indicator: None,
1955            multi_leg_reporting_type: None,
1956            text: None,
1957            exec_cost: None,
1958            exec_comm: None,
1959            home_notional: None,
1960            foreign_notional: None,
1961            timestamp: None,
1962        };
1963
1964        let mut instrument_def = create_test_perpetual_instrument();
1965        instrument_def.symbol = Ustr::from("ETHUSD");
1966        instrument_def.underlying = Ustr::from("ETH");
1967        instrument_def.quote_currency = Ustr::from("USD");
1968        instrument_def.settl_currency = Some(Ustr::from("USDt"));
1969        let instrument = parse_perpetual_instrument(&instrument_def, UnixNanos::default()).unwrap();
1970
1971        let report = parse_fill_report(exec, &instrument, UnixNanos::from(1)).unwrap();
1972
1973        assert_eq!(report.account_id.to_string(), "BITMEX-111111");
1974        assert_eq!(report.instrument_id.to_string(), "ETHUSD.BITMEX");
1975        assert_eq!(
1976            report.trade_id.to_string(),
1977            "f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6"
1978        );
1979        assert!(report.client_order_id.is_none());
1980        assert_eq!(report.commission.as_f64(), 0.0);
1981        assert_eq!(report.commission.currency.code.as_str(), "XBT");
1982        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1983    }
1984
1985    #[rstest]
1986    fn test_parse_position_report() {
1987        let position = BitmexPosition {
1988            account: 789012,
1989            symbol: Ustr::from("XBTUSD"),
1990            current_qty: Some(1000),
1991            timestamp: Some(
1992                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1993                    .unwrap()
1994                    .with_timezone(&Utc),
1995            ),
1996            currency: None,
1997            underlying: None,
1998            quote_currency: None,
1999            commission: None,
2000            init_margin_req: None,
2001            maint_margin_req: None,
2002            risk_limit: None,
2003            leverage: None,
2004            cross_margin: None,
2005            deleverage_percentile: None,
2006            rebalanced_pnl: None,
2007            prev_realised_pnl: None,
2008            prev_unrealised_pnl: None,
2009            prev_close_price: None,
2010            opening_timestamp: None,
2011            opening_qty: None,
2012            opening_cost: None,
2013            opening_comm: None,
2014            open_order_buy_qty: None,
2015            open_order_buy_cost: None,
2016            open_order_buy_premium: None,
2017            open_order_sell_qty: None,
2018            open_order_sell_cost: None,
2019            open_order_sell_premium: None,
2020            exec_buy_qty: None,
2021            exec_buy_cost: None,
2022            exec_sell_qty: None,
2023            exec_sell_cost: None,
2024            exec_qty: None,
2025            exec_cost: None,
2026            exec_comm: None,
2027            current_timestamp: None,
2028            current_cost: None,
2029            current_comm: None,
2030            realised_cost: None,
2031            unrealised_cost: None,
2032            gross_open_cost: None,
2033            gross_open_premium: None,
2034            gross_exec_cost: None,
2035            is_open: Some(true),
2036            mark_price: None,
2037            mark_value: None,
2038            risk_value: None,
2039            home_notional: None,
2040            foreign_notional: None,
2041            pos_state: None,
2042            pos_cost: None,
2043            pos_cost2: None,
2044            pos_cross: None,
2045            pos_init: None,
2046            pos_comm: None,
2047            pos_loss: None,
2048            pos_margin: None,
2049            pos_maint: None,
2050            pos_allowance: None,
2051            taxable_margin: None,
2052            init_margin: None,
2053            maint_margin: None,
2054            session_margin: None,
2055            target_excess_margin: None,
2056            var_margin: None,
2057            realised_gross_pnl: None,
2058            realised_tax: None,
2059            realised_pnl: None,
2060            unrealised_gross_pnl: None,
2061            long_bankrupt: None,
2062            short_bankrupt: None,
2063            tax_base: None,
2064            indicative_tax_rate: None,
2065            indicative_tax: None,
2066            unrealised_tax: None,
2067            unrealised_pnl: None,
2068            unrealised_pnl_pcnt: None,
2069            unrealised_roe_pcnt: None,
2070            avg_cost_price: None,
2071            avg_entry_price: None,
2072            break_even_price: None,
2073            margin_call_price: None,
2074            liquidation_price: None,
2075            bankrupt_price: None,
2076            last_price: None,
2077            last_value: None,
2078        };
2079
2080        let instrument =
2081            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
2082                .unwrap();
2083
2084        let report = parse_position_report(position, &instrument, UnixNanos::from(1)).unwrap();
2085
2086        assert_eq!(report.account_id.to_string(), "BITMEX-789012");
2087        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
2088        assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
2089        assert_eq!(report.quantity.as_f64(), 1000.0);
2090    }
2091
2092    #[rstest]
2093    fn test_parse_position_report_short() {
2094        let position = BitmexPosition {
2095            account: 789012,
2096            symbol: Ustr::from("ETHUSD"),
2097            current_qty: Some(-500),
2098            timestamp: Some(
2099                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2100                    .unwrap()
2101                    .with_timezone(&Utc),
2102            ),
2103            currency: None,
2104            underlying: None,
2105            quote_currency: None,
2106            commission: None,
2107            init_margin_req: None,
2108            maint_margin_req: None,
2109            risk_limit: None,
2110            leverage: None,
2111            cross_margin: None,
2112            deleverage_percentile: None,
2113            rebalanced_pnl: None,
2114            prev_realised_pnl: None,
2115            prev_unrealised_pnl: None,
2116            prev_close_price: None,
2117            opening_timestamp: None,
2118            opening_qty: None,
2119            opening_cost: None,
2120            opening_comm: None,
2121            open_order_buy_qty: None,
2122            open_order_buy_cost: None,
2123            open_order_buy_premium: None,
2124            open_order_sell_qty: None,
2125            open_order_sell_cost: None,
2126            open_order_sell_premium: None,
2127            exec_buy_qty: None,
2128            exec_buy_cost: None,
2129            exec_sell_qty: None,
2130            exec_sell_cost: None,
2131            exec_qty: None,
2132            exec_cost: None,
2133            exec_comm: None,
2134            current_timestamp: None,
2135            current_cost: None,
2136            current_comm: None,
2137            realised_cost: None,
2138            unrealised_cost: None,
2139            gross_open_cost: None,
2140            gross_open_premium: None,
2141            gross_exec_cost: None,
2142            is_open: Some(true),
2143            mark_price: None,
2144            mark_value: None,
2145            risk_value: None,
2146            home_notional: None,
2147            foreign_notional: None,
2148            pos_state: None,
2149            pos_cost: None,
2150            pos_cost2: None,
2151            pos_cross: None,
2152            pos_init: None,
2153            pos_comm: None,
2154            pos_loss: None,
2155            pos_margin: None,
2156            pos_maint: None,
2157            pos_allowance: None,
2158            taxable_margin: None,
2159            init_margin: None,
2160            maint_margin: None,
2161            session_margin: None,
2162            target_excess_margin: None,
2163            var_margin: None,
2164            realised_gross_pnl: None,
2165            realised_tax: None,
2166            realised_pnl: None,
2167            unrealised_gross_pnl: None,
2168            long_bankrupt: None,
2169            short_bankrupt: None,
2170            tax_base: None,
2171            indicative_tax_rate: None,
2172            indicative_tax: None,
2173            unrealised_tax: None,
2174            unrealised_pnl: None,
2175            unrealised_pnl_pcnt: None,
2176            unrealised_roe_pcnt: None,
2177            avg_cost_price: None,
2178            avg_entry_price: None,
2179            break_even_price: None,
2180            margin_call_price: None,
2181            liquidation_price: None,
2182            bankrupt_price: None,
2183            last_price: None,
2184            last_value: None,
2185        };
2186
2187        let mut instrument_def = create_test_futures_instrument();
2188        instrument_def.symbol = Ustr::from("ETHUSD");
2189        instrument_def.underlying = Ustr::from("ETH");
2190        instrument_def.quote_currency = Ustr::from("USD");
2191        instrument_def.settl_currency = Some(Ustr::from("USD"));
2192        let instrument = parse_futures_instrument(&instrument_def, UnixNanos::default()).unwrap();
2193
2194        let report = parse_position_report(position, &instrument, UnixNanos::from(1)).unwrap();
2195
2196        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
2197        assert_eq!(report.quantity.as_f64(), 500.0); // Should be absolute value
2198    }
2199
2200    #[rstest]
2201    fn test_parse_position_report_flat() {
2202        let position = BitmexPosition {
2203            account: 789012,
2204            symbol: Ustr::from("SOLUSD"),
2205            current_qty: Some(0),
2206            timestamp: Some(
2207                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2208                    .unwrap()
2209                    .with_timezone(&Utc),
2210            ),
2211            currency: None,
2212            underlying: None,
2213            quote_currency: None,
2214            commission: None,
2215            init_margin_req: None,
2216            maint_margin_req: None,
2217            risk_limit: None,
2218            leverage: None,
2219            cross_margin: None,
2220            deleverage_percentile: None,
2221            rebalanced_pnl: None,
2222            prev_realised_pnl: None,
2223            prev_unrealised_pnl: None,
2224            prev_close_price: None,
2225            opening_timestamp: None,
2226            opening_qty: None,
2227            opening_cost: None,
2228            opening_comm: None,
2229            open_order_buy_qty: None,
2230            open_order_buy_cost: None,
2231            open_order_buy_premium: None,
2232            open_order_sell_qty: None,
2233            open_order_sell_cost: None,
2234            open_order_sell_premium: None,
2235            exec_buy_qty: None,
2236            exec_buy_cost: None,
2237            exec_sell_qty: None,
2238            exec_sell_cost: None,
2239            exec_qty: None,
2240            exec_cost: None,
2241            exec_comm: None,
2242            current_timestamp: None,
2243            current_cost: None,
2244            current_comm: None,
2245            realised_cost: None,
2246            unrealised_cost: None,
2247            gross_open_cost: None,
2248            gross_open_premium: None,
2249            gross_exec_cost: None,
2250            is_open: Some(true),
2251            mark_price: None,
2252            mark_value: None,
2253            risk_value: None,
2254            home_notional: None,
2255            foreign_notional: None,
2256            pos_state: None,
2257            pos_cost: None,
2258            pos_cost2: None,
2259            pos_cross: None,
2260            pos_init: None,
2261            pos_comm: None,
2262            pos_loss: None,
2263            pos_margin: None,
2264            pos_maint: None,
2265            pos_allowance: None,
2266            taxable_margin: None,
2267            init_margin: None,
2268            maint_margin: None,
2269            session_margin: None,
2270            target_excess_margin: None,
2271            var_margin: None,
2272            realised_gross_pnl: None,
2273            realised_tax: None,
2274            realised_pnl: None,
2275            unrealised_gross_pnl: None,
2276            long_bankrupt: None,
2277            short_bankrupt: None,
2278            tax_base: None,
2279            indicative_tax_rate: None,
2280            indicative_tax: None,
2281            unrealised_tax: None,
2282            unrealised_pnl: None,
2283            unrealised_pnl_pcnt: None,
2284            unrealised_roe_pcnt: None,
2285            avg_cost_price: None,
2286            avg_entry_price: None,
2287            break_even_price: None,
2288            margin_call_price: None,
2289            liquidation_price: None,
2290            bankrupt_price: None,
2291            last_price: None,
2292            last_value: None,
2293        };
2294
2295        let mut instrument_def = create_test_spot_instrument();
2296        instrument_def.symbol = Ustr::from("SOLUSD");
2297        instrument_def.underlying = Ustr::from("SOL");
2298        instrument_def.quote_currency = Ustr::from("USD");
2299        let instrument = parse_spot_instrument(&instrument_def, UnixNanos::default()).unwrap();
2300
2301        let report = parse_position_report(position, &instrument, UnixNanos::from(1)).unwrap();
2302
2303        assert_eq!(report.position_side.as_position_side(), PositionSide::Flat);
2304        assert_eq!(report.quantity.as_f64(), 0.0);
2305    }
2306
2307    #[rstest]
2308    fn test_parse_position_report_spot_scaling() {
2309        let position = BitmexPosition {
2310            account: 789012,
2311            symbol: Ustr::from("SOLUSD"),
2312            current_qty: Some(1000),
2313            timestamp: Some(
2314                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2315                    .unwrap()
2316                    .with_timezone(&Utc),
2317            ),
2318            currency: None,
2319            underlying: None,
2320            quote_currency: None,
2321            commission: None,
2322            init_margin_req: None,
2323            maint_margin_req: None,
2324            risk_limit: None,
2325            leverage: None,
2326            cross_margin: None,
2327            deleverage_percentile: None,
2328            rebalanced_pnl: None,
2329            prev_realised_pnl: None,
2330            prev_unrealised_pnl: None,
2331            prev_close_price: None,
2332            opening_timestamp: None,
2333            opening_qty: None,
2334            opening_cost: None,
2335            opening_comm: None,
2336            open_order_buy_qty: None,
2337            open_order_buy_cost: None,
2338            open_order_buy_premium: None,
2339            open_order_sell_qty: None,
2340            open_order_sell_cost: None,
2341            open_order_sell_premium: None,
2342            exec_buy_qty: None,
2343            exec_buy_cost: None,
2344            exec_sell_qty: None,
2345            exec_sell_cost: None,
2346            exec_qty: None,
2347            exec_cost: None,
2348            exec_comm: None,
2349            current_timestamp: None,
2350            current_cost: None,
2351            current_comm: None,
2352            realised_cost: None,
2353            unrealised_cost: None,
2354            gross_open_cost: None,
2355            gross_open_premium: None,
2356            gross_exec_cost: None,
2357            is_open: Some(true),
2358            mark_price: None,
2359            mark_value: None,
2360            risk_value: None,
2361            home_notional: None,
2362            foreign_notional: None,
2363            pos_state: None,
2364            pos_cost: None,
2365            pos_cost2: None,
2366            pos_cross: None,
2367            pos_init: None,
2368            pos_comm: None,
2369            pos_loss: None,
2370            pos_margin: None,
2371            pos_maint: None,
2372            pos_allowance: None,
2373            taxable_margin: None,
2374            init_margin: None,
2375            maint_margin: None,
2376            session_margin: None,
2377            target_excess_margin: None,
2378            var_margin: None,
2379            realised_gross_pnl: None,
2380            realised_tax: None,
2381            realised_pnl: None,
2382            unrealised_gross_pnl: None,
2383            long_bankrupt: None,
2384            short_bankrupt: None,
2385            tax_base: None,
2386            indicative_tax_rate: None,
2387            indicative_tax: None,
2388            unrealised_tax: None,
2389            unrealised_pnl: None,
2390            unrealised_pnl_pcnt: None,
2391            unrealised_roe_pcnt: None,
2392            avg_cost_price: None,
2393            avg_entry_price: None,
2394            break_even_price: None,
2395            margin_call_price: None,
2396            liquidation_price: None,
2397            bankrupt_price: None,
2398            last_price: None,
2399            last_value: None,
2400        };
2401
2402        let mut instrument_def = create_test_spot_instrument();
2403        instrument_def.symbol = Ustr::from("SOLUSD");
2404        instrument_def.underlying = Ustr::from("SOL");
2405        instrument_def.quote_currency = Ustr::from("USD");
2406        let instrument = parse_spot_instrument(&instrument_def, UnixNanos::default()).unwrap();
2407
2408        let report = parse_position_report(position, &instrument, UnixNanos::from(1)).unwrap();
2409
2410        assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
2411        assert!((report.quantity.as_f64() - 0.1).abs() < 1e-9);
2412    }
2413
2414    fn create_test_spot_instrument() -> BitmexInstrument {
2415        BitmexInstrument {
2416            symbol: Ustr::from("XBTUSD"),
2417            root_symbol: Ustr::from("XBT"),
2418            state: BitmexInstrumentState::Open,
2419            instrument_type: BitmexInstrumentType::Spot,
2420            listing: Some(
2421                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
2422                    .unwrap()
2423                    .with_timezone(&Utc),
2424            ),
2425            front: Some(
2426                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
2427                    .unwrap()
2428                    .with_timezone(&Utc),
2429            ),
2430            expiry: None,
2431            settle: None,
2432            listed_settle: None,
2433            position_currency: Some(Ustr::from("USD")),
2434            underlying: Ustr::from("XBT"),
2435            quote_currency: Ustr::from("USD"),
2436            underlying_symbol: Some(Ustr::from("XBT=")),
2437            reference: Some(Ustr::from("BMEX")),
2438            reference_symbol: Some(Ustr::from(".BXBT")),
2439            lot_size: Some(1000.0),
2440            tick_size: 0.01,
2441            multiplier: 1.0,
2442            settl_currency: Some(Ustr::from("USD")),
2443            is_quanto: false,
2444            is_inverse: false,
2445            maker_fee: Some(-0.00025),
2446            taker_fee: Some(0.00075),
2447            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
2448                .unwrap()
2449                .with_timezone(&Utc),
2450            // Set other fields to reasonable defaults
2451            max_order_qty: Some(10000000.0),
2452            max_price: Some(1000000.0),
2453            min_price: None,
2454            settlement_fee: Some(0.0),
2455            mark_price: Some(50500.0),
2456            last_price: Some(50500.0),
2457            bid_price: Some(50499.5),
2458            ask_price: Some(50500.5),
2459            open_interest: Some(0.0),
2460            open_value: Some(0.0),
2461            total_volume: Some(1000000.0),
2462            volume: Some(50000.0),
2463            volume_24h: Some(75000.0),
2464            total_turnover: Some(150000000.0),
2465            turnover: Some(5000000.0),
2466            turnover_24h: Some(7500000.0),
2467            has_liquidity: Some(true),
2468            // Set remaining fields to None/defaults
2469            calc_interval: None,
2470            publish_interval: None,
2471            publish_time: None,
2472            underlying_to_position_multiplier: Some(10000.0),
2473            underlying_to_settle_multiplier: None,
2474            quote_to_settle_multiplier: Some(1.0),
2475            init_margin: Some(0.1),
2476            maint_margin: Some(0.05),
2477            risk_limit: Some(20000000000.0),
2478            risk_step: Some(10000000000.0),
2479            limit: None,
2480            taxed: Some(true),
2481            deleverage: Some(true),
2482            funding_base_symbol: None,
2483            funding_quote_symbol: None,
2484            funding_premium_symbol: None,
2485            funding_timestamp: None,
2486            funding_interval: None,
2487            funding_rate: None,
2488            indicative_funding_rate: None,
2489            rebalance_timestamp: None,
2490            rebalance_interval: None,
2491            prev_close_price: Some(50000.0),
2492            limit_down_price: None,
2493            limit_up_price: None,
2494            prev_total_turnover: Some(100000000.0),
2495            home_notional_24h: Some(1.5),
2496            foreign_notional_24h: Some(75000.0),
2497            prev_price_24h: Some(49500.0),
2498            vwap: Some(50100.0),
2499            high_price: Some(51000.0),
2500            low_price: Some(49000.0),
2501            last_price_protected: Some(50500.0),
2502            last_tick_direction: Some(BitmexTickDirection::PlusTick),
2503            last_change_pcnt: Some(0.0202),
2504            mid_price: Some(50500.0),
2505            impact_bid_price: Some(50490.0),
2506            impact_mid_price: Some(50495.0),
2507            impact_ask_price: Some(50500.0),
2508            fair_method: None,
2509            fair_basis_rate: None,
2510            fair_basis: None,
2511            fair_price: None,
2512            mark_method: Some(BitmexMarkMethod::LastPrice),
2513            indicative_settle_price: None,
2514            settled_price_adjustment_rate: None,
2515            settled_price: None,
2516            instant_pnl: false,
2517            min_tick: None,
2518            funding_base_rate: None,
2519            funding_quote_rate: None,
2520            capped: None,
2521            opening_timestamp: None,
2522            closing_timestamp: None,
2523            prev_total_volume: None,
2524        }
2525    }
2526
2527    fn create_test_perpetual_instrument() -> BitmexInstrument {
2528        BitmexInstrument {
2529            symbol: Ustr::from("XBTUSD"),
2530            root_symbol: Ustr::from("XBT"),
2531            state: BitmexInstrumentState::Open,
2532            instrument_type: BitmexInstrumentType::PerpetualContract,
2533            listing: Some(
2534                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
2535                    .unwrap()
2536                    .with_timezone(&Utc),
2537            ),
2538            front: Some(
2539                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
2540                    .unwrap()
2541                    .with_timezone(&Utc),
2542            ),
2543            expiry: None,
2544            settle: None,
2545            listed_settle: None,
2546            position_currency: Some(Ustr::from("USD")),
2547            underlying: Ustr::from("XBT"),
2548            quote_currency: Ustr::from("USD"),
2549            underlying_symbol: Some(Ustr::from("XBT=")),
2550            reference: Some(Ustr::from("BMEX")),
2551            reference_symbol: Some(Ustr::from(".BXBT")),
2552            lot_size: Some(100.0),
2553            tick_size: 0.5,
2554            multiplier: -100000000.0,
2555            settl_currency: Some(Ustr::from("XBt")),
2556            is_quanto: false,
2557            is_inverse: true,
2558            maker_fee: Some(-0.00025),
2559            taker_fee: Some(0.00075),
2560            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
2561                .unwrap()
2562                .with_timezone(&Utc),
2563            // Set other fields
2564            max_order_qty: Some(10000000.0),
2565            max_price: Some(1000000.0),
2566            min_price: None,
2567            settlement_fee: Some(0.0),
2568            mark_price: Some(50500.01),
2569            last_price: Some(50500.0),
2570            bid_price: Some(50499.5),
2571            ask_price: Some(50500.5),
2572            open_interest: Some(500000000.0),
2573            open_value: Some(990099009900.0),
2574            total_volume: Some(12345678900000.0),
2575            volume: Some(5000000.0),
2576            volume_24h: Some(75000000.0),
2577            total_turnover: Some(150000000000000.0),
2578            turnover: Some(5000000000.0),
2579            turnover_24h: Some(7500000000.0),
2580            has_liquidity: Some(true),
2581            // Perpetual specific fields
2582            funding_base_symbol: Some(Ustr::from(".XBTBON8H")),
2583            funding_quote_symbol: Some(Ustr::from(".USDBON8H")),
2584            funding_premium_symbol: Some(Ustr::from(".XBTUSDPI8H")),
2585            funding_timestamp: Some(
2586                DateTime::parse_from_rfc3339("2024-01-01T08:00:00.000Z")
2587                    .unwrap()
2588                    .with_timezone(&Utc),
2589            ),
2590            funding_interval: Some(
2591                DateTime::parse_from_rfc3339("2000-01-01T08:00:00.000Z")
2592                    .unwrap()
2593                    .with_timezone(&Utc),
2594            ),
2595            funding_rate: Some(Decimal::from_str("0.0001").unwrap()),
2596            indicative_funding_rate: Some(Decimal::from_str("0.0001").unwrap()),
2597            funding_base_rate: Some(0.01),
2598            funding_quote_rate: Some(-0.01),
2599            // Other fields
2600            calc_interval: None,
2601            publish_interval: None,
2602            publish_time: None,
2603            underlying_to_position_multiplier: None,
2604            underlying_to_settle_multiplier: Some(-100000000.0),
2605            quote_to_settle_multiplier: None,
2606            init_margin: Some(0.01),
2607            maint_margin: Some(0.005),
2608            risk_limit: Some(20000000000.0),
2609            risk_step: Some(10000000000.0),
2610            limit: None,
2611            taxed: Some(true),
2612            deleverage: Some(true),
2613            rebalance_timestamp: None,
2614            rebalance_interval: None,
2615            prev_close_price: Some(50000.0),
2616            limit_down_price: None,
2617            limit_up_price: None,
2618            prev_total_turnover: Some(100000000000000.0),
2619            home_notional_24h: Some(1500.0),
2620            foreign_notional_24h: Some(75000000.0),
2621            prev_price_24h: Some(49500.0),
2622            vwap: Some(50100.0),
2623            high_price: Some(51000.0),
2624            low_price: Some(49000.0),
2625            last_price_protected: Some(50500.0),
2626            last_tick_direction: Some(BitmexTickDirection::PlusTick),
2627            last_change_pcnt: Some(0.0202),
2628            mid_price: Some(50500.0),
2629            impact_bid_price: Some(50490.0),
2630            impact_mid_price: Some(50495.0),
2631            impact_ask_price: Some(50500.0),
2632            fair_method: Some(BitmexFairMethod::FundingRate),
2633            fair_basis_rate: Some(0.1095),
2634            fair_basis: Some(0.01),
2635            fair_price: Some(50500.01),
2636            mark_method: Some(BitmexMarkMethod::FairPrice),
2637            indicative_settle_price: Some(50500.0),
2638            settled_price_adjustment_rate: None,
2639            settled_price: None,
2640            instant_pnl: false,
2641            min_tick: None,
2642            capped: None,
2643            opening_timestamp: None,
2644            closing_timestamp: None,
2645            prev_total_volume: None,
2646        }
2647    }
2648
2649    fn create_test_futures_instrument() -> BitmexInstrument {
2650        BitmexInstrument {
2651            symbol: Ustr::from("XBTH25"),
2652            root_symbol: Ustr::from("XBT"),
2653            state: BitmexInstrumentState::Open,
2654            instrument_type: BitmexInstrumentType::Futures,
2655            listing: Some(
2656                DateTime::parse_from_rfc3339("2024-09-27T12:00:00.000Z")
2657                    .unwrap()
2658                    .with_timezone(&Utc),
2659            ),
2660            front: Some(
2661                DateTime::parse_from_rfc3339("2024-12-27T12:00:00.000Z")
2662                    .unwrap()
2663                    .with_timezone(&Utc),
2664            ),
2665            expiry: Some(
2666                DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
2667                    .unwrap()
2668                    .with_timezone(&Utc),
2669            ),
2670            settle: Some(
2671                DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
2672                    .unwrap()
2673                    .with_timezone(&Utc),
2674            ),
2675            listed_settle: None,
2676            position_currency: Some(Ustr::from("USD")),
2677            underlying: Ustr::from("XBT"),
2678            quote_currency: Ustr::from("USD"),
2679            underlying_symbol: Some(Ustr::from("XBT=")),
2680            reference: Some(Ustr::from("BMEX")),
2681            reference_symbol: Some(Ustr::from(".BXBT30M")),
2682            lot_size: Some(100.0),
2683            tick_size: 0.5,
2684            multiplier: -100000000.0,
2685            settl_currency: Some(Ustr::from("XBt")),
2686            is_quanto: false,
2687            is_inverse: true,
2688            maker_fee: Some(-0.00025),
2689            taker_fee: Some(0.00075),
2690            settlement_fee: Some(0.0005),
2691            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
2692                .unwrap()
2693                .with_timezone(&Utc),
2694            // Set other fields
2695            max_order_qty: Some(10000000.0),
2696            max_price: Some(1000000.0),
2697            min_price: None,
2698            mark_price: Some(55500.0),
2699            last_price: Some(55500.0),
2700            bid_price: Some(55499.5),
2701            ask_price: Some(55500.5),
2702            open_interest: Some(50000000.0),
2703            open_value: Some(90090090090.0),
2704            total_volume: Some(1000000000.0),
2705            volume: Some(500000.0),
2706            volume_24h: Some(7500000.0),
2707            total_turnover: Some(15000000000000.0),
2708            turnover: Some(500000000.0),
2709            turnover_24h: Some(750000000.0),
2710            has_liquidity: Some(true),
2711            // Futures specific fields
2712            funding_base_symbol: None,
2713            funding_quote_symbol: None,
2714            funding_premium_symbol: None,
2715            funding_timestamp: None,
2716            funding_interval: None,
2717            funding_rate: None,
2718            indicative_funding_rate: None,
2719            funding_base_rate: None,
2720            funding_quote_rate: None,
2721            // Other fields
2722            calc_interval: None,
2723            publish_interval: None,
2724            publish_time: None,
2725            underlying_to_position_multiplier: None,
2726            underlying_to_settle_multiplier: Some(-100000000.0),
2727            quote_to_settle_multiplier: None,
2728            init_margin: Some(0.02),
2729            maint_margin: Some(0.01),
2730            risk_limit: Some(20000000000.0),
2731            risk_step: Some(10000000000.0),
2732            limit: None,
2733            taxed: Some(true),
2734            deleverage: Some(true),
2735            rebalance_timestamp: None,
2736            rebalance_interval: None,
2737            prev_close_price: Some(55000.0),
2738            limit_down_price: None,
2739            limit_up_price: None,
2740            prev_total_turnover: Some(10000000000000.0),
2741            home_notional_24h: Some(150.0),
2742            foreign_notional_24h: Some(7500000.0),
2743            prev_price_24h: Some(54500.0),
2744            vwap: Some(55100.0),
2745            high_price: Some(56000.0),
2746            low_price: Some(54000.0),
2747            last_price_protected: Some(55500.0),
2748            last_tick_direction: Some(BitmexTickDirection::PlusTick),
2749            last_change_pcnt: Some(0.0183),
2750            mid_price: Some(55500.0),
2751            impact_bid_price: Some(55490.0),
2752            impact_mid_price: Some(55495.0),
2753            impact_ask_price: Some(55500.0),
2754            fair_method: Some(BitmexFairMethod::ImpactMidPrice),
2755            fair_basis_rate: Some(1.8264),
2756            fair_basis: Some(1000.0),
2757            fair_price: Some(55500.0),
2758            mark_method: Some(BitmexMarkMethod::FairPrice),
2759            indicative_settle_price: Some(55500.0),
2760            settled_price_adjustment_rate: None,
2761            settled_price: None,
2762            instant_pnl: false,
2763            min_tick: None,
2764            capped: None,
2765            opening_timestamp: None,
2766            closing_timestamp: None,
2767            prev_total_volume: None,
2768        }
2769    }
2770
2771    #[rstest]
2772    fn test_parse_spot_instrument() {
2773        let instrument = create_test_spot_instrument();
2774        let ts_init = UnixNanos::default();
2775        let result = parse_spot_instrument(&instrument, ts_init).unwrap();
2776
2777        // Check it's a CurrencyPair variant
2778        match result {
2779            InstrumentAny::CurrencyPair(spot) => {
2780                assert_eq!(spot.id.symbol.as_str(), "XBTUSD");
2781                assert_eq!(spot.id.venue.as_str(), "BITMEX");
2782                assert_eq!(spot.raw_symbol.as_str(), "XBTUSD");
2783                assert_eq!(spot.price_precision, 2);
2784                assert_eq!(spot.size_precision, 4);
2785                assert_eq!(spot.price_increment.as_f64(), 0.01);
2786                assert!((spot.size_increment.as_f64() - 0.0001).abs() < 1e-9);
2787                assert!((spot.lot_size.unwrap().as_f64() - 0.1).abs() < 1e-9);
2788                assert_eq!(spot.maker_fee.to_f64().unwrap(), -0.00025);
2789                assert_eq!(spot.taker_fee.to_f64().unwrap(), 0.00075);
2790            }
2791            _ => panic!("Expected CurrencyPair variant"),
2792        }
2793    }
2794
2795    #[rstest]
2796    fn test_parse_perpetual_instrument() {
2797        let instrument = create_test_perpetual_instrument();
2798        let ts_init = UnixNanos::default();
2799        let result = parse_perpetual_instrument(&instrument, ts_init).unwrap();
2800
2801        // Check it's a CryptoPerpetual variant
2802        match result {
2803            InstrumentAny::CryptoPerpetual(perp) => {
2804                assert_eq!(perp.id.symbol.as_str(), "XBTUSD");
2805                assert_eq!(perp.id.venue.as_str(), "BITMEX");
2806                assert_eq!(perp.raw_symbol.as_str(), "XBTUSD");
2807                assert_eq!(perp.price_precision, 1);
2808                assert_eq!(perp.size_precision, 0);
2809                assert_eq!(perp.price_increment.as_f64(), 0.5);
2810                assert_eq!(perp.size_increment.as_f64(), 1.0);
2811                assert_eq!(perp.maker_fee.to_f64().unwrap(), -0.00025);
2812                assert_eq!(perp.taker_fee.to_f64().unwrap(), 0.00075);
2813                assert!(perp.is_inverse);
2814            }
2815            _ => panic!("Expected CryptoPerpetual variant"),
2816        }
2817    }
2818
2819    #[rstest]
2820    fn test_parse_futures_instrument() {
2821        let instrument = create_test_futures_instrument();
2822        let ts_init = UnixNanos::default();
2823        let result = parse_futures_instrument(&instrument, ts_init).unwrap();
2824
2825        // Check it's a CryptoFuture variant
2826        match result {
2827            InstrumentAny::CryptoFuture(instrument) => {
2828                assert_eq!(instrument.id.symbol.as_str(), "XBTH25");
2829                assert_eq!(instrument.id.venue.as_str(), "BITMEX");
2830                assert_eq!(instrument.raw_symbol.as_str(), "XBTH25");
2831                assert_eq!(instrument.underlying.code.as_str(), "XBT");
2832                assert_eq!(instrument.price_precision, 1);
2833                assert_eq!(instrument.size_precision, 0);
2834                assert_eq!(instrument.price_increment.as_f64(), 0.5);
2835                assert_eq!(instrument.size_increment.as_f64(), 1.0);
2836                assert_eq!(instrument.maker_fee.to_f64().unwrap(), -0.00025);
2837                assert_eq!(instrument.taker_fee.to_f64().unwrap(), 0.00075);
2838                assert!(instrument.is_inverse);
2839                // Check expiration timestamp instead of expiry_date
2840                // The futures contract expires on 2025-03-28
2841                assert!(instrument.expiration_ns.as_u64() > 0);
2842            }
2843            _ => panic!("Expected CryptoFuture variant"),
2844        }
2845    }
2846
2847    #[rstest]
2848    fn test_parse_order_status_report_missing_ord_status_infers_filled() {
2849        let order = BitmexOrder {
2850            account: 123456,
2851            symbol: Some(Ustr::from("XBTUSD")),
2852            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
2853            cl_ord_id: Some(Ustr::from("client-filled")),
2854            cl_ord_link_id: None,
2855            side: Some(BitmexSide::Buy),
2856            ord_type: Some(BitmexOrderType::Limit),
2857            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
2858            ord_status: None, // Missing - should infer Filled
2859            order_qty: Some(100),
2860            cum_qty: Some(100), // Fully filled
2861            price: Some(50000.0),
2862            stop_px: None,
2863            display_qty: None,
2864            peg_offset_value: None,
2865            peg_price_type: None,
2866            currency: Some(Ustr::from("USD")),
2867            settl_currency: Some(Ustr::from("XBt")),
2868            exec_inst: None,
2869            contingency_type: None,
2870            ex_destination: None,
2871            triggered: None,
2872            working_indicator: Some(false),
2873            ord_rej_reason: None,
2874            leaves_qty: Some(0), // No remaining quantity
2875            avg_px: Some(50050.0),
2876            multi_leg_reporting_type: None,
2877            text: None,
2878            transact_time: Some(
2879                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2880                    .unwrap()
2881                    .with_timezone(&Utc),
2882            ),
2883            timestamp: Some(
2884                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
2885                    .unwrap()
2886                    .with_timezone(&Utc),
2887            ),
2888        };
2889
2890        let instrument =
2891            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
2892                .unwrap();
2893        let report =
2894            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
2895                .unwrap();
2896
2897        assert_eq!(report.order_status, OrderStatus::Filled);
2898        assert_eq!(report.account_id.to_string(), "BITMEX-123456");
2899        assert_eq!(report.filled_qty.as_f64(), 100.0);
2900    }
2901
2902    #[rstest]
2903    fn test_parse_order_status_report_missing_ord_status_infers_canceled() {
2904        let order = BitmexOrder {
2905            account: 123456,
2906            symbol: Some(Ustr::from("XBTUSD")),
2907            order_id: Uuid::parse_str("b2c3d4e5-f6a7-8901-bcde-f12345678901").unwrap(),
2908            cl_ord_id: Some(Ustr::from("client-canceled")),
2909            cl_ord_link_id: None,
2910            side: Some(BitmexSide::Sell),
2911            ord_type: Some(BitmexOrderType::Limit),
2912            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
2913            ord_status: None, // Missing - should infer Canceled
2914            order_qty: Some(200),
2915            cum_qty: Some(0), // Nothing filled
2916            price: Some(60000.0),
2917            stop_px: None,
2918            display_qty: None,
2919            peg_offset_value: None,
2920            peg_price_type: None,
2921            currency: Some(Ustr::from("USD")),
2922            settl_currency: Some(Ustr::from("XBt")),
2923            exec_inst: None,
2924            contingency_type: None,
2925            ex_destination: None,
2926            triggered: None,
2927            working_indicator: Some(false),
2928            ord_rej_reason: None,
2929            leaves_qty: Some(0), // No remaining quantity
2930            avg_px: None,
2931            multi_leg_reporting_type: None,
2932            text: Some(Ustr::from("Canceled: Already filled")),
2933            transact_time: Some(
2934                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2935                    .unwrap()
2936                    .with_timezone(&Utc),
2937            ),
2938            timestamp: Some(
2939                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
2940                    .unwrap()
2941                    .with_timezone(&Utc),
2942            ),
2943        };
2944
2945        let instrument =
2946            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
2947                .unwrap();
2948        let report =
2949            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
2950                .unwrap();
2951
2952        assert_eq!(report.order_status, OrderStatus::Canceled);
2953        assert_eq!(report.account_id.to_string(), "BITMEX-123456");
2954        assert_eq!(report.filled_qty.as_f64(), 0.0);
2955        // Verify text/reason is still captured
2956        assert_eq!(
2957            report.cancel_reason.as_ref().unwrap(),
2958            "Canceled: Already filled"
2959        );
2960    }
2961
2962    #[rstest]
2963    fn test_parse_order_status_report_missing_ord_status_with_leaves_qty_fails() {
2964        let order = BitmexOrder {
2965            account: 123456,
2966            symbol: Some(Ustr::from("XBTUSD")),
2967            order_id: Uuid::parse_str("c3d4e5f6-a7b8-9012-cdef-123456789012").unwrap(),
2968            cl_ord_id: Some(Ustr::from("client-partial")),
2969            cl_ord_link_id: None,
2970            side: Some(BitmexSide::Buy),
2971            ord_type: Some(BitmexOrderType::Limit),
2972            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
2973            ord_status: None, // Missing
2974            order_qty: Some(100),
2975            cum_qty: Some(50),
2976            price: Some(50000.0),
2977            stop_px: None,
2978            display_qty: None,
2979            peg_offset_value: None,
2980            peg_price_type: None,
2981            currency: Some(Ustr::from("USD")),
2982            settl_currency: Some(Ustr::from("XBt")),
2983            exec_inst: None,
2984            contingency_type: None,
2985            ex_destination: None,
2986            triggered: None,
2987            working_indicator: Some(true),
2988            ord_rej_reason: None,
2989            leaves_qty: Some(50), // Still has remaining qty - can't infer status
2990            avg_px: None,
2991            multi_leg_reporting_type: None,
2992            text: None,
2993            transact_time: Some(
2994                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
2995                    .unwrap()
2996                    .with_timezone(&Utc),
2997            ),
2998            timestamp: Some(
2999                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3000                    .unwrap()
3001                    .with_timezone(&Utc),
3002            ),
3003        };
3004
3005        let instrument =
3006            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3007                .unwrap();
3008        let result =
3009            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
3010
3011        assert!(result.is_err());
3012        let err_msg = result.unwrap_err().to_string();
3013        assert!(err_msg.contains("missing ord_status"));
3014        assert!(err_msg.contains("cannot infer"));
3015    }
3016
3017    #[rstest]
3018    fn test_parse_order_status_report_missing_ord_status_no_quantities_fails() {
3019        let order = BitmexOrder {
3020            account: 123456,
3021            symbol: Some(Ustr::from("XBTUSD")),
3022            order_id: Uuid::parse_str("d4e5f6a7-b8c9-0123-def0-123456789013").unwrap(),
3023            cl_ord_id: Some(Ustr::from("client-unknown")),
3024            cl_ord_link_id: None,
3025            side: Some(BitmexSide::Buy),
3026            ord_type: Some(BitmexOrderType::Limit),
3027            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3028            ord_status: None, // Missing
3029            order_qty: Some(100),
3030            cum_qty: None, // Missing
3031            price: Some(50000.0),
3032            stop_px: None,
3033            display_qty: None,
3034            peg_offset_value: None,
3035            peg_price_type: None,
3036            currency: Some(Ustr::from("USD")),
3037            settl_currency: Some(Ustr::from("XBt")),
3038            exec_inst: None,
3039            contingency_type: None,
3040            ex_destination: None,
3041            triggered: None,
3042            working_indicator: Some(true),
3043            ord_rej_reason: None,
3044            leaves_qty: None, // Missing
3045            avg_px: None,
3046            multi_leg_reporting_type: None,
3047            text: None,
3048            transact_time: Some(
3049                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3050                    .unwrap()
3051                    .with_timezone(&Utc),
3052            ),
3053            timestamp: Some(
3054                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3055                    .unwrap()
3056                    .with_timezone(&Utc),
3057            ),
3058        };
3059
3060        let instrument =
3061            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3062                .unwrap();
3063        let result =
3064            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
3065
3066        assert!(result.is_err());
3067        let err_msg = result.unwrap_err().to_string();
3068        assert!(err_msg.contains("missing ord_status"));
3069        assert!(err_msg.contains("cannot infer"));
3070    }
3071
3072    #[rstest]
3073    fn test_parse_order_status_report_infers_market_order_type() {
3074        // Missing ord_type, no price, no stop_px -> Market
3075        let order = BitmexOrder {
3076            account: 123456,
3077            symbol: Some(Ustr::from("XBTUSD")),
3078            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
3079            cl_ord_id: Some(Ustr::from("client-123")),
3080            cl_ord_link_id: None,
3081            side: Some(BitmexSide::Buy),
3082            ord_type: None,
3083            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3084            ord_status: Some(BitmexOrderStatus::Filled),
3085            order_qty: Some(100),
3086            cum_qty: Some(100),
3087            price: None,
3088            stop_px: None,
3089            display_qty: None,
3090            peg_offset_value: None,
3091            peg_price_type: None,
3092            currency: Some(Ustr::from("USD")),
3093            settl_currency: Some(Ustr::from("XBt")),
3094            exec_inst: None,
3095            contingency_type: None,
3096            ex_destination: None,
3097            triggered: None,
3098            working_indicator: None,
3099            ord_rej_reason: None,
3100            leaves_qty: Some(0),
3101            avg_px: Some(50000.0),
3102            multi_leg_reporting_type: None,
3103            text: None,
3104            transact_time: Some(
3105                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3106                    .unwrap()
3107                    .with_timezone(&Utc),
3108            ),
3109            timestamp: Some(
3110                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3111                    .unwrap()
3112                    .with_timezone(&Utc),
3113            ),
3114        };
3115
3116        let instrument =
3117            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3118                .unwrap();
3119        let report =
3120            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
3121                .unwrap();
3122
3123        assert_eq!(report.order_type, OrderType::Market);
3124    }
3125
3126    #[rstest]
3127    fn test_parse_order_status_report_infers_limit_order_type() {
3128        // Missing ord_type, has price, no stop_px -> Limit
3129        let order = BitmexOrder {
3130            account: 123456,
3131            symbol: Some(Ustr::from("XBTUSD")),
3132            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
3133            cl_ord_id: Some(Ustr::from("client-123")),
3134            cl_ord_link_id: None,
3135            side: Some(BitmexSide::Buy),
3136            ord_type: None,
3137            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3138            ord_status: Some(BitmexOrderStatus::New),
3139            order_qty: Some(100),
3140            cum_qty: Some(0),
3141            price: Some(50000.0),
3142            stop_px: None,
3143            display_qty: None,
3144            peg_offset_value: None,
3145            peg_price_type: None,
3146            currency: Some(Ustr::from("USD")),
3147            settl_currency: Some(Ustr::from("XBt")),
3148            exec_inst: None,
3149            contingency_type: None,
3150            ex_destination: None,
3151            triggered: None,
3152            working_indicator: Some(true),
3153            ord_rej_reason: None,
3154            leaves_qty: Some(100),
3155            avg_px: None,
3156            multi_leg_reporting_type: None,
3157            text: None,
3158            transact_time: Some(
3159                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3160                    .unwrap()
3161                    .with_timezone(&Utc),
3162            ),
3163            timestamp: Some(
3164                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3165                    .unwrap()
3166                    .with_timezone(&Utc),
3167            ),
3168        };
3169
3170        let instrument =
3171            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3172                .unwrap();
3173        let report =
3174            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
3175                .unwrap();
3176
3177        assert_eq!(report.order_type, OrderType::Limit);
3178    }
3179
3180    #[rstest]
3181    fn test_parse_order_status_report_infers_stop_market_order_type() {
3182        // Missing ord_type, no price, has stop_px -> StopMarket
3183        let order = BitmexOrder {
3184            account: 123456,
3185            symbol: Some(Ustr::from("XBTUSD")),
3186            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
3187            cl_ord_id: Some(Ustr::from("client-123")),
3188            cl_ord_link_id: None,
3189            side: Some(BitmexSide::Sell),
3190            ord_type: None,
3191            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3192            ord_status: Some(BitmexOrderStatus::New),
3193            order_qty: Some(100),
3194            cum_qty: Some(0),
3195            price: None,
3196            stop_px: Some(45000.0),
3197            display_qty: None,
3198            peg_offset_value: None,
3199            peg_price_type: None,
3200            currency: Some(Ustr::from("USD")),
3201            settl_currency: Some(Ustr::from("XBt")),
3202            exec_inst: None,
3203            contingency_type: None,
3204            ex_destination: None,
3205            triggered: None,
3206            working_indicator: Some(false),
3207            ord_rej_reason: None,
3208            leaves_qty: Some(100),
3209            avg_px: None,
3210            multi_leg_reporting_type: None,
3211            text: None,
3212            transact_time: Some(
3213                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3214                    .unwrap()
3215                    .with_timezone(&Utc),
3216            ),
3217            timestamp: Some(
3218                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3219                    .unwrap()
3220                    .with_timezone(&Utc),
3221            ),
3222        };
3223
3224        let instrument =
3225            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3226                .unwrap();
3227        let report =
3228            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
3229                .unwrap();
3230
3231        assert_eq!(report.order_type, OrderType::StopMarket);
3232    }
3233
3234    #[rstest]
3235    fn test_parse_order_status_report_infers_stop_limit_order_type() {
3236        // Missing ord_type, has price and stop_px -> StopLimit
3237        let order = BitmexOrder {
3238            account: 123456,
3239            symbol: Some(Ustr::from("XBTUSD")),
3240            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
3241            cl_ord_id: Some(Ustr::from("client-123")),
3242            cl_ord_link_id: None,
3243            side: Some(BitmexSide::Sell),
3244            ord_type: None,
3245            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3246            ord_status: Some(BitmexOrderStatus::New),
3247            order_qty: Some(100),
3248            cum_qty: Some(0),
3249            price: Some(44000.0),
3250            stop_px: Some(45000.0),
3251            display_qty: None,
3252            peg_offset_value: None,
3253            peg_price_type: None,
3254            currency: Some(Ustr::from("USD")),
3255            settl_currency: Some(Ustr::from("XBt")),
3256            exec_inst: None,
3257            contingency_type: None,
3258            ex_destination: None,
3259            triggered: None,
3260            working_indicator: Some(false),
3261            ord_rej_reason: None,
3262            leaves_qty: Some(100),
3263            avg_px: None,
3264            multi_leg_reporting_type: None,
3265            text: None,
3266            transact_time: Some(
3267                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3268                    .unwrap()
3269                    .with_timezone(&Utc),
3270            ),
3271            timestamp: Some(
3272                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3273                    .unwrap()
3274                    .with_timezone(&Utc),
3275            ),
3276        };
3277
3278        let instrument =
3279            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3280                .unwrap();
3281        let report =
3282            parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
3283                .unwrap();
3284
3285        assert_eq!(report.order_type, OrderType::StopLimit);
3286    }
3287
3288    #[rstest]
3289    fn test_parse_order_status_report_uses_cached_order_type() {
3290        // Missing ord_type but cache has the order type -> use cached value
3291        let order = BitmexOrder {
3292            account: 123456,
3293            symbol: Some(Ustr::from("XBTUSD")),
3294            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
3295            cl_ord_id: Some(Ustr::from("client-123")),
3296            cl_ord_link_id: None,
3297            side: Some(BitmexSide::Buy),
3298            ord_type: None,
3299            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
3300            ord_status: Some(BitmexOrderStatus::Canceled),
3301            order_qty: None,
3302            cum_qty: Some(0),
3303            price: None,
3304            stop_px: None,
3305            display_qty: None,
3306            peg_offset_value: None,
3307            peg_price_type: None,
3308            currency: Some(Ustr::from("USD")),
3309            settl_currency: Some(Ustr::from("XBt")),
3310            exec_inst: None,
3311            contingency_type: None,
3312            ex_destination: None,
3313            triggered: None,
3314            working_indicator: None,
3315            ord_rej_reason: None,
3316            leaves_qty: Some(0),
3317            avg_px: None,
3318            multi_leg_reporting_type: None,
3319            text: None,
3320            transact_time: Some(
3321                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
3322                    .unwrap()
3323                    .with_timezone(&Utc),
3324            ),
3325            timestamp: Some(
3326                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
3327                    .unwrap()
3328                    .with_timezone(&Utc),
3329            ),
3330        };
3331
3332        let instrument =
3333            parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
3334                .unwrap();
3335
3336        // Pre-populate cache with StopLimit (would be inferred as Market without cache)
3337        let cache: DashMap<ClientOrderId, OrderType> = DashMap::new();
3338        cache.insert(ClientOrderId::new("client-123"), OrderType::StopLimit);
3339
3340        let report =
3341            parse_order_status_report(&order, &instrument, &cache, UnixNanos::from(1)).unwrap();
3342
3343        assert_eq!(report.order_type, OrderType::StopLimit);
3344    }
3345}