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