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