nautilus_bitmex/http/
parse.rs

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