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