Skip to main content

nautilus_bitmex/http/
parse.rs

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