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