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