nautilus_bitmex/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::str::FromStr;
17
18use nautilus_core::{UnixNanos, time::get_atomic_clock_realtime, uuid::UUID4};
19use nautilus_model::{
20    currencies::CURRENCY_MAP,
21    data::TradeTick,
22    enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TriggerType},
23    identifiers::{
24        AccountId, ClientOrderId, InstrumentId, OrderListId, Symbol, TradeId, VenueOrderId,
25    },
26    instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, InstrumentAny},
27    reports::{FillReport, OrderStatusReport, PositionStatusReport},
28    types::{Currency, Money, Price, Quantity},
29};
30use rust_decimal::Decimal;
31use ustr::Ustr;
32use uuid::Uuid;
33
34use super::models::{BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTrade};
35use crate::common::{
36    enums::{BitmexExecInstruction, BitmexExecType, BitmexInstrumentType},
37    parse::{
38        map_bitmex_currency, parse_aggressor_side, parse_instrument_id, parse_liquidity_side,
39        parse_optional_datetime_to_unix_nanos, parse_position_side,
40    },
41};
42
43#[must_use]
44pub fn parse_instrument_any(
45    instrument: &BitmexInstrument,
46    ts_init: UnixNanos,
47) -> Option<InstrumentAny> {
48    match instrument.instrument_type {
49        BitmexInstrumentType::Spot => parse_spot_instrument(instrument, ts_init)
50            .map_err(|e| {
51                tracing::warn!("Failed to parse spot instrument {}: {e}", instrument.symbol);
52                e
53            })
54            .ok(),
55        BitmexInstrumentType::PerpetualContract | BitmexInstrumentType::PerpetualContractFx => {
56            // Handle both crypto and FX perpetuals the same way
57            parse_perpetual_instrument(instrument, ts_init)
58                .map_err(|e| {
59                    tracing::warn!(
60                        "Failed to parse perpetual instrument {}: {e}",
61                        instrument.symbol,
62                    );
63                    e
64                })
65                .ok()
66        }
67        BitmexInstrumentType::Futures => parse_futures_instrument(instrument, ts_init)
68            .map_err(|e| {
69                tracing::warn!(
70                    "Failed to parse futures instrument {}: {e}",
71                    instrument.symbol,
72                );
73                e
74            })
75            .ok(),
76        BitmexInstrumentType::BasketIndex
77        | BitmexInstrumentType::CryptoIndex
78        | BitmexInstrumentType::FxIndex
79        | BitmexInstrumentType::LendingIndex
80        | BitmexInstrumentType::VolatilityIndex => {
81            // Parse index instruments as perpetuals for cache purposes
82            // They need to be in cache for WebSocket price updates
83            parse_index_instrument(instrument, ts_init)
84                .map_err(|e| {
85                    tracing::warn!(
86                        "Failed to parse index instrument {}: {}",
87                        instrument.symbol,
88                        e
89                    );
90                    e
91                })
92                .ok()
93        }
94        _ => {
95            tracing::warn!(
96                "Unsupported instrument type {:?} for symbol {}",
97                instrument.instrument_type,
98                instrument.symbol
99            );
100            None
101        }
102    }
103}
104
105/// Parse a BitMEX index instrument into a Nautilus `InstrumentAny`.
106///
107/// Index instruments are parsed as perpetuals with minimal fields to support
108/// price update lookups in the WebSocket.
109///
110/// # Errors
111///
112/// Returns an error if values are out of valid range or cannot be parsed.
113pub fn parse_index_instrument(
114    definition: &BitmexInstrument,
115    ts_init: UnixNanos,
116) -> anyhow::Result<InstrumentAny> {
117    let instrument_id = parse_instrument_id(definition.symbol);
118    let raw_symbol = Symbol::new(definition.symbol);
119
120    let base_currency = Currency::USD();
121    let quote_currency = Currency::USD();
122    let settlement_currency = Currency::USD();
123
124    let price_increment = Price::from(definition.tick_size.to_string());
125    let size_increment = Quantity::from(1); // Indices don't have tradeable sizes
126
127    Ok(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
128        instrument_id,
129        raw_symbol,
130        base_currency,
131        quote_currency,
132        settlement_currency,
133        false, // is_inverse
134        price_increment.precision,
135        size_increment.precision,
136        price_increment,
137        size_increment,
138        None, // multiplier
139        None, // lot_size
140        None, // max_quantity
141        None, // min_quantity
142        None, // max_notional
143        None, // min_notional
144        None, // max_price
145        None, // min_price
146        None, // margin_init
147        None, // margin_maint
148        None, // maker_fee
149        None, // taker_fee
150        ts_init,
151        ts_init,
152    )))
153}
154
155/// Parse a BitMEX spot instrument into a Nautilus `InstrumentAny`.
156///
157/// # Errors
158///
159/// Returns an error if values are out of valid range or cannot be parsed.
160pub fn parse_spot_instrument(
161    definition: &BitmexInstrument,
162    ts_init: UnixNanos,
163) -> anyhow::Result<InstrumentAny> {
164    let instrument_id = parse_instrument_id(definition.symbol);
165    let raw_symbol = Symbol::new(definition.symbol);
166    let base_currency = get_currency(definition.underlying.to_uppercase());
167    let quote_currency = get_currency(definition.quote_currency.to_uppercase());
168
169    let price_increment = Price::from(definition.tick_size.to_string());
170
171    // For spot instruments, calculate the actual lot size using underlyingToPositionMultiplier
172    // BitMEX spot uses lot_size / underlyingToPositionMultiplier for the actual minimum size
173    let actual_lot_size = if let Some(multiplier) = definition.underlying_to_position_multiplier {
174        definition.lot_size.unwrap_or(1.0) / multiplier
175    } else {
176        definition.lot_size.unwrap_or(1.0)
177    };
178
179    let size_increment = Quantity::from(actual_lot_size.to_string());
180
181    let taker_fee = definition
182        .taker_fee
183        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
184        .unwrap_or(Decimal::ZERO);
185    let maker_fee = definition
186        .maker_fee
187        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
188        .unwrap_or(Decimal::ZERO);
189
190    let margin_init = definition
191        .init_margin
192        .as_ref()
193        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
194        .unwrap_or(Decimal::ZERO);
195    let margin_maint = definition
196        .maint_margin
197        .as_ref()
198        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
199        .unwrap_or(Decimal::ZERO);
200
201    let lot_size = definition
202        .lot_size
203        .map(|size| Quantity::new_checked(size, 0))
204        .transpose()?;
205    let max_quantity = definition
206        .max_order_qty
207        .map(|qty| Quantity::new_checked(qty, 0))
208        .transpose()?;
209    let min_quantity = definition
210        .lot_size
211        .map(|size| Quantity::new_checked(size, 0))
212        .transpose()?;
213    let max_notional: Option<Money> = None;
214    let min_notional: Option<Money> = None;
215    let max_price = definition
216        .max_price
217        .map(|price| Price::from(price.to_string()));
218    let min_price = None;
219    let ts_event = UnixNanos::from(definition.timestamp);
220
221    let instrument = CurrencyPair::new(
222        instrument_id,
223        raw_symbol,
224        base_currency,
225        quote_currency,
226        price_increment.precision,
227        size_increment.precision,
228        price_increment,
229        size_increment,
230        None, // multiplier
231        lot_size,
232        max_quantity,
233        min_quantity,
234        max_notional,
235        min_notional,
236        max_price,
237        min_price,
238        Some(margin_init),
239        Some(margin_maint),
240        Some(maker_fee),
241        Some(taker_fee),
242        ts_event,
243        ts_init,
244    );
245
246    Ok(InstrumentAny::CurrencyPair(instrument))
247}
248
249/// Parse a BitMEX perpetual instrument into a Nautilus `InstrumentAny`.
250///
251/// # Errors
252///
253/// Returns an error if values are out of valid range or cannot be parsed.
254pub fn parse_perpetual_instrument(
255    definition: &BitmexInstrument,
256    ts_init: UnixNanos,
257) -> anyhow::Result<InstrumentAny> {
258    let instrument_id = parse_instrument_id(definition.symbol);
259    let raw_symbol = Symbol::new(definition.symbol);
260    let base_currency = get_currency(definition.underlying.to_uppercase());
261    let quote_currency = get_currency(definition.quote_currency.to_uppercase());
262    let settlement_currency = get_currency(definition.settl_currency.as_ref().map_or_else(
263        || definition.quote_currency.to_uppercase(),
264        |s| s.to_uppercase(),
265    ));
266    let is_inverse = definition.is_inverse;
267
268    let price_increment = Price::from(definition.tick_size.to_string());
269
270    // For perpetual instruments, lot_size is typically already correct (usually 1.0)
271    // But we should still check underlyingToPositionMultiplier for consistency
272    let actual_lot_size = if let Some(multiplier) = definition.underlying_to_position_multiplier {
273        definition.lot_size.unwrap_or(1.0) / multiplier
274    } else {
275        definition.lot_size.unwrap_or(1.0)
276    };
277
278    let size_increment = Quantity::from(actual_lot_size.to_string());
279
280    let taker_fee = definition
281        .taker_fee
282        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
283        .unwrap_or(Decimal::ZERO);
284    let maker_fee = definition
285        .maker_fee
286        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
287        .unwrap_or(Decimal::ZERO);
288
289    let margin_init = definition
290        .init_margin
291        .as_ref()
292        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
293        .unwrap_or(Decimal::ZERO);
294    let margin_maint = definition
295        .maint_margin
296        .as_ref()
297        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
298        .unwrap_or(Decimal::ZERO);
299
300    // TODO: How to handle negative multipliers?
301    let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
302    let lot_size = definition
303        .lot_size
304        .map(|size| Quantity::new_checked(size, 0))
305        .transpose()?;
306    let max_quantity = definition
307        .max_order_qty
308        .map(|qty| Quantity::new_checked(qty, 0))
309        .transpose()?;
310    let min_quantity = definition
311        .lot_size
312        .map(|size| Quantity::new_checked(size, 0))
313        .transpose()?;
314    let max_notional: Option<Money> = None;
315    let min_notional: Option<Money> = None;
316    let max_price = definition
317        .max_price
318        .map(|price| Price::from(price.to_string()));
319    let min_price = None;
320    let ts_event = UnixNanos::from(definition.timestamp);
321
322    let instrument = CryptoPerpetual::new(
323        instrument_id,
324        raw_symbol,
325        base_currency,
326        quote_currency,
327        settlement_currency,
328        is_inverse,
329        price_increment.precision,
330        size_increment.precision,
331        price_increment,
332        size_increment,
333        multiplier,
334        lot_size,
335        max_quantity,
336        min_quantity,
337        max_notional,
338        min_notional,
339        max_price,
340        min_price,
341        Some(margin_init),
342        Some(margin_maint),
343        Some(maker_fee),
344        Some(taker_fee),
345        ts_event,
346        ts_init,
347    );
348
349    Ok(InstrumentAny::CryptoPerpetual(instrument))
350}
351
352/// Parse a BitMEX futures instrument into a Nautilus `InstrumentAny`.
353///
354/// # Errors
355///
356/// Returns an error if values are out of valid range or cannot be parsed.
357pub fn parse_futures_instrument(
358    definition: &BitmexInstrument,
359    ts_init: UnixNanos,
360) -> anyhow::Result<InstrumentAny> {
361    let instrument_id = parse_instrument_id(definition.symbol);
362    let raw_symbol = Symbol::new(definition.symbol);
363    let underlying = get_currency(definition.underlying.to_uppercase());
364    let quote_currency = get_currency(definition.quote_currency.to_uppercase());
365    let settlement_currency = get_currency(definition.settl_currency.as_ref().map_or_else(
366        || definition.quote_currency.to_uppercase(),
367        |s| s.to_uppercase(),
368    ));
369    let is_inverse = definition.is_inverse;
370
371    let activation_ns = UnixNanos::from(definition.listing);
372    let expiration_ns = parse_optional_datetime_to_unix_nanos(&definition.expiry, "expiry");
373    let price_increment = Price::from(definition.tick_size.to_string());
374
375    // For futures instruments, lot_size is typically already correct (usually 1.0)
376    // But we should still check underlyingToPositionMultiplier for consistency
377    let actual_lot_size = if let Some(multiplier) = definition.underlying_to_position_multiplier {
378        definition.lot_size.unwrap_or(1.0) / multiplier
379    } else {
380        definition.lot_size.unwrap_or(1.0)
381    };
382
383    let size_increment = Quantity::from(actual_lot_size.to_string());
384
385    let taker_fee = definition
386        .taker_fee
387        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
388        .unwrap_or(Decimal::ZERO);
389    let maker_fee = definition
390        .maker_fee
391        .and_then(|fee| Decimal::from_str(&fee.to_string()).ok())
392        .unwrap_or(Decimal::ZERO);
393
394    let margin_init = definition
395        .init_margin
396        .as_ref()
397        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
398        .unwrap_or(Decimal::ZERO);
399    let margin_maint = definition
400        .maint_margin
401        .as_ref()
402        .and_then(|margin| Decimal::from_str(&margin.to_string()).ok())
403        .unwrap_or(Decimal::ZERO);
404
405    // TODO: How to handle negative multipliers?
406    let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
407
408    let lot_size = definition
409        .lot_size
410        .map(|size| Quantity::new_checked(size, 0))
411        .transpose()?;
412    let max_quantity = definition
413        .max_order_qty
414        .map(|qty| Quantity::new_checked(qty, 0))
415        .transpose()?;
416    let min_quantity = definition
417        .lot_size
418        .map(|size| Quantity::new_checked(size, 0))
419        .transpose()?;
420    let max_notional: Option<Money> = None;
421    let min_notional: Option<Money> = None;
422    let max_price = definition
423        .max_price
424        .map(|price| Price::from(price.to_string()));
425    let min_price = None;
426    let ts_event = UnixNanos::from(definition.timestamp);
427
428    let instrument = CryptoFuture::new(
429        instrument_id,
430        raw_symbol,
431        underlying,
432        quote_currency,
433        settlement_currency,
434        is_inverse,
435        activation_ns,
436        expiration_ns,
437        price_increment.precision,
438        size_increment.precision,
439        price_increment,
440        size_increment,
441        multiplier,
442        lot_size,
443        max_quantity,
444        min_quantity,
445        max_notional,
446        min_notional,
447        max_price,
448        min_price,
449        Some(margin_init),
450        Some(margin_maint),
451        Some(maker_fee),
452        Some(taker_fee),
453        ts_event,
454        ts_init,
455    );
456
457    Ok(InstrumentAny::CryptoFuture(instrument))
458}
459
460/// Parse a BitMEX trade into a Nautilus `TradeTick`.
461///
462/// # Errors
463///
464/// Currently this function does not return errors as all fields are handled gracefully,
465/// but returns `Result` for future error handling compatibility.
466pub fn parse_trade(
467    trade: BitmexTrade,
468    price_precision: u8,
469    ts_init: UnixNanos,
470) -> anyhow::Result<TradeTick> {
471    let instrument_id = parse_instrument_id(trade.symbol);
472    let price = Price::new(trade.price, price_precision);
473    let size = Quantity::from(trade.size);
474    let aggressor_side = parse_aggressor_side(&trade.side);
475    let trade_id = TradeId::new(
476        trade
477            .trd_match_id
478            .map_or_else(|| Uuid::new_v4().to_string(), |uuid| uuid.to_string()),
479    );
480    let ts_event = UnixNanos::from(trade.timestamp);
481
482    Ok(TradeTick::new(
483        instrument_id,
484        price,
485        size,
486        aggressor_side,
487        trade_id,
488        ts_event,
489        ts_init,
490    ))
491}
492
493/// Parse a BitMEX order into a Nautilus `OrderStatusReport`.
494///
495/// # Errors
496///
497/// Currently this function does not return errors as all fields are handled gracefully,
498/// but returns `Result` for future error handling compatibility.
499///
500/// # Panics
501///
502/// Panics if:
503/// - Order is missing required fields: `symbol`, `ord_type`, `time_in_force`, `ord_status`, or `order_qty`
504/// - Unsupported `ExecInstruction` type is encountered (other than `ParticipateDoNotInitiate` or `ReduceOnly`)
505pub fn parse_order_status_report(
506    order: &BitmexOrder,
507    instrument_id: InstrumentId,
508    price_precision: u8,
509    ts_init: UnixNanos,
510) -> anyhow::Result<OrderStatusReport> {
511    let account_id = AccountId::new(format!("BITMEX-{}", order.account));
512    let venue_order_id = VenueOrderId::new(order.order_id.to_string());
513    let order_side: OrderSide = order
514        .side
515        .map_or(OrderSide::NoOrderSide, |side| side.into());
516    let order_type: OrderType = (*order
517        .ord_type
518        .as_ref()
519        .ok_or_else(|| anyhow::anyhow!("Order missing ord_type"))?)
520    .into();
521    let time_in_force: nautilus_model::enums::TimeInForce = (*order
522        .time_in_force
523        .as_ref()
524        .ok_or_else(|| anyhow::anyhow!("Order missing time_in_force"))?)
525    .try_into()
526    .map_err(|e| anyhow::anyhow!("{e}"))?;
527    let order_status: OrderStatus = (*order
528        .ord_status
529        .as_ref()
530        .ok_or_else(|| anyhow::anyhow!("Order missing ord_status"))?)
531    .into();
532    let quantity = Quantity::from(
533        order
534            .order_qty
535            .ok_or_else(|| anyhow::anyhow!("Order missing order_qty"))?,
536    );
537    let filled_qty = Quantity::from(order.cum_qty.unwrap_or(0));
538    let report_id = UUID4::new();
539    let ts_accepted = order.transact_time.map_or_else(
540        || get_atomic_clock_realtime().get_time_ns(),
541        UnixNanos::from,
542    );
543    let ts_last = order.timestamp.map_or_else(
544        || get_atomic_clock_realtime().get_time_ns(),
545        UnixNanos::from,
546    );
547
548    let mut report = OrderStatusReport::new(
549        account_id,
550        instrument_id,
551        None, // client_order_id - will be set later if present
552        venue_order_id,
553        order_side,
554        order_type,
555        time_in_force,
556        order_status,
557        quantity,
558        filled_qty,
559        ts_accepted,
560        ts_last,
561        ts_init,
562        Some(report_id),
563    );
564
565    if let Some(cl_ord_id) = order.cl_ord_id {
566        report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
567    }
568
569    if let Some(cl_ord_link_id) = order.cl_ord_link_id {
570        report = report.with_order_list_id(OrderListId::new(cl_ord_link_id));
571    }
572
573    if let Some(price) = order.price {
574        report = report.with_price(Price::new(price, price_precision));
575    }
576
577    if let Some(avg_px) = order.avg_px {
578        report = report.with_avg_px(avg_px);
579    }
580
581    if let Some(trigger_price) = order.stop_px {
582        report = report
583            .with_trigger_price(Price::new(trigger_price, price_precision))
584            .with_trigger_type(TriggerType::Default);
585    }
586
587    if let Some(exec_instructions) = &order.exec_inst {
588        for inst in exec_instructions {
589            match inst {
590                BitmexExecInstruction::ParticipateDoNotInitiate => {
591                    report = report.with_post_only(true);
592                }
593                BitmexExecInstruction::ReduceOnly => report = report.with_reduce_only(true),
594                BitmexExecInstruction::LastPrice
595                | BitmexExecInstruction::Close
596                | BitmexExecInstruction::MarkPrice
597                | BitmexExecInstruction::IndexPrice
598                | BitmexExecInstruction::AllOrNone
599                | BitmexExecInstruction::Fixed
600                | BitmexExecInstruction::Unknown => {}
601            }
602        }
603    }
604
605    if let Some(contingency_type) = order.contingency_type {
606        report = report.with_contingency_type(contingency_type.into());
607    }
608
609    // if let Some(expire_time) = order.ex {
610    //     report = report.with_trigger_price(Price::new(trigger_price, price_precision));
611    // }
612
613    Ok(report)
614}
615
616/// Parse a BitMEX execution into a Nautilus `FillReport`.
617///
618/// # Errors
619///
620/// Currently this function does not return errors as all fields are handled gracefully,
621/// but returns `Result` for future error handling compatibility.
622///
623/// # Panics
624///
625/// Panics if:
626/// - Execution is missing required fields: `symbol`, `order_id`, `trd_match_id`, `last_qty`, `last_px`, or `transact_time`
627pub fn parse_fill_report(
628    exec: BitmexExecution,
629    price_precision: u8,
630    ts_init: UnixNanos,
631) -> anyhow::Result<FillReport> {
632    // Skip non-trade executions (funding, settlements, etc.)
633    // Trade executions have exec_type of Trade and must have order_id
634    if !matches!(exec.exec_type, BitmexExecType::Trade) {
635        return Err(anyhow::anyhow!(
636            "Skipping non-trade execution: {:?}",
637            exec.exec_type
638        ));
639    }
640
641    // Additional check: skip executions without order_id (likely funding/settlement)
642    let order_id = exec.order_id.ok_or_else(|| {
643        anyhow::anyhow!("Skipping execution without order_id: {:?}", exec.exec_type)
644    })?;
645
646    let account_id = AccountId::new(format!("BITMEX-{}", exec.account));
647    let symbol = exec
648        .symbol
649        .ok_or_else(|| anyhow::anyhow!("Execution missing symbol"))?;
650    let instrument_id = parse_instrument_id(symbol);
651    let venue_order_id = VenueOrderId::new(order_id.to_string());
652    // trd_match_id might be missing for some execution types, use exec_id as fallback
653    let trade_id = TradeId::new(
654        exec.trd_match_id
655            .or(Some(exec.exec_id))
656            .ok_or_else(|| anyhow::anyhow!("Fill missing both trd_match_id and exec_id"))?
657            .to_string(),
658    );
659    // Skip executions without side (likely not trades)
660    let Some(side) = exec.side else {
661        return Err(anyhow::anyhow!(
662            "Skipping execution without side: {:?}",
663            exec.exec_type
664        ));
665    };
666    let order_side: OrderSide = side.into();
667    let last_qty = Quantity::from(exec.last_qty);
668    let last_px = Price::new(exec.last_px, price_precision);
669
670    // Map BitMEX currency to standard currency code
671    let settlement_currency_str = exec.settl_currency.unwrap_or(Ustr::from("XBT")).as_str();
672    let mapped_currency = map_bitmex_currency(settlement_currency_str);
673    let commission = Money::new(
674        exec.commission.unwrap_or(0.0),
675        Currency::from(mapped_currency.as_str()),
676    );
677    let liquidity_side = parse_liquidity_side(&exec.last_liquidity_ind);
678    let client_order_id = exec.cl_ord_id.map(ClientOrderId::new);
679    let venue_position_id = None; // Not applicable on BitMEX
680    let ts_event = exec.transact_time.map_or_else(
681        || get_atomic_clock_realtime().get_time_ns(),
682        UnixNanos::from,
683    );
684
685    Ok(FillReport::new(
686        account_id,
687        instrument_id,
688        venue_order_id,
689        trade_id,
690        order_side,
691        last_qty,
692        last_px,
693        commission,
694        liquidity_side,
695        client_order_id,
696        venue_position_id,
697        ts_event,
698        ts_init,
699        None,
700    ))
701}
702
703/// Parse a BitMEX position into a Nautilus `PositionStatusReport`.
704///
705/// # Errors
706///
707/// Currently this function does not return errors as all fields are handled gracefully,
708/// but returns `Result` for future error handling compatibility.
709pub fn parse_position_report(
710    position: BitmexPosition,
711    ts_init: UnixNanos,
712) -> anyhow::Result<PositionStatusReport> {
713    let account_id = AccountId::new(format!("BITMEX-{}", position.account));
714    let instrument_id = parse_instrument_id(position.symbol);
715    let position_side = parse_position_side(position.current_qty).as_specified();
716    let quantity = Quantity::from(position.current_qty.map_or(0_i64, i64::abs));
717    let venue_position_id = None; // Not applicable on BitMEX
718    let ts_last = parse_optional_datetime_to_unix_nanos(&position.timestamp, "timestamp");
719
720    Ok(PositionStatusReport::new(
721        account_id,
722        instrument_id,
723        position_side,
724        quantity,
725        venue_position_id,
726        ts_last,
727        ts_init,
728        None,
729    ))
730}
731
732/// Returns the currency either from the internal currency map or creates a default crypto.
733fn get_currency(code: String) -> Currency {
734    CURRENCY_MAP
735        .lock()
736        .unwrap()
737        .get(&code)
738        .copied()
739        .unwrap_or(Currency::new(&code, 8, 0, &code, CurrencyType::Crypto))
740}
741
742////////////////////////////////////////////////////////////////////////////////
743// Tests
744////////////////////////////////////////////////////////////////////////////////
745
746#[cfg(test)]
747mod tests {
748    use chrono::{DateTime, Utc};
749    use nautilus_model::enums::{LiquiditySide, PositionSide};
750    use rstest::rstest;
751    use rust_decimal::prelude::ToPrimitive;
752    use uuid::Uuid;
753
754    use super::*;
755    use crate::{
756        common::{
757            enums::{
758                BitmexContingencyType, BitmexFairMethod, BitmexInstrumentState,
759                BitmexInstrumentType, BitmexLiquidityIndicator, BitmexMarkMethod,
760                BitmexOrderStatus, BitmexOrderType, BitmexSide, BitmexTickDirection,
761                BitmexTimeInForce,
762            },
763            testing::load_test_json,
764        },
765        http::models::{
766            BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTradeBin,
767            BitmexWallet,
768        },
769    };
770
771    #[rstest]
772    fn test_perp_instrument_deserialization() {
773        let json_data = load_test_json("http_get_instrument_xbtusd.json");
774        let instrument: BitmexInstrument = serde_json::from_str(&json_data).unwrap();
775
776        assert_eq!(instrument.symbol, "XBTUSD");
777        assert_eq!(instrument.root_symbol, "XBT");
778        assert_eq!(instrument.state, BitmexInstrumentState::Open);
779        assert!(instrument.is_inverse);
780        assert_eq!(instrument.maker_fee, Some(0.0005));
781        assert_eq!(
782            instrument.timestamp.to_rfc3339(),
783            "2024-11-24T23:33:19.034+00:00"
784        );
785    }
786
787    #[rstest]
788    fn test_parse_orders() {
789        let json_data = load_test_json("http_get_orders.json");
790        let orders: Vec<BitmexOrder> = serde_json::from_str(&json_data).unwrap();
791
792        assert_eq!(orders.len(), 2);
793
794        // Test first order (New)
795        let order1 = &orders[0];
796        assert_eq!(order1.symbol, Some(Ustr::from("XBTUSD")));
797        assert_eq!(order1.side, Some(BitmexSide::Buy));
798        assert_eq!(order1.order_qty, Some(100));
799        assert_eq!(order1.price, Some(98000.0));
800        assert_eq!(order1.ord_status, Some(BitmexOrderStatus::New));
801        assert_eq!(order1.leaves_qty, Some(100));
802        assert_eq!(order1.cum_qty, Some(0));
803
804        // Test second order (Filled)
805        let order2 = &orders[1];
806        assert_eq!(order2.symbol, Some(Ustr::from("XBTUSD")));
807        assert_eq!(order2.side, Some(BitmexSide::Sell));
808        assert_eq!(order2.order_qty, Some(200));
809        assert_eq!(order2.ord_status, Some(BitmexOrderStatus::Filled));
810        assert_eq!(order2.leaves_qty, Some(0));
811        assert_eq!(order2.cum_qty, Some(200));
812        assert_eq!(order2.avg_px, Some(98950.5));
813    }
814
815    #[rstest]
816    fn test_parse_executions() {
817        let json_data = load_test_json("http_get_executions.json");
818        let executions: Vec<BitmexExecution> = serde_json::from_str(&json_data).unwrap();
819
820        assert_eq!(executions.len(), 2);
821
822        // Test first execution (Maker)
823        let exec1 = &executions[0];
824        assert_eq!(exec1.symbol, Some(Ustr::from("XBTUSD")));
825        assert_eq!(exec1.side, Some(BitmexSide::Sell));
826        assert_eq!(exec1.last_qty, 100);
827        assert_eq!(exec1.last_px, 98950.0);
828        assert_eq!(
829            exec1.last_liquidity_ind,
830            Some(BitmexLiquidityIndicator::Maker)
831        );
832        assert_eq!(exec1.commission, Some(0.00075));
833
834        // Test second execution (Taker)
835        let exec2 = &executions[1];
836        assert_eq!(
837            exec2.last_liquidity_ind,
838            Some(BitmexLiquidityIndicator::Taker)
839        );
840        assert_eq!(exec2.last_px, 98951.0);
841    }
842
843    #[rstest]
844    fn test_parse_positions() {
845        let json_data = load_test_json("http_get_positions.json");
846        let positions: Vec<BitmexPosition> = serde_json::from_str(&json_data).unwrap();
847
848        assert_eq!(positions.len(), 1);
849
850        let position = &positions[0];
851        assert_eq!(position.account, 1234567);
852        assert_eq!(position.symbol, "XBTUSD");
853        assert_eq!(position.current_qty, Some(100));
854        assert_eq!(position.avg_entry_price, Some(98390.88));
855        assert_eq!(position.unrealised_pnl, Some(1350));
856        assert_eq!(position.realised_pnl, Some(-227));
857        assert_eq!(position.is_open, Some(true));
858    }
859
860    #[rstest]
861    fn test_parse_trades() {
862        let json_data = load_test_json("http_get_trades.json");
863        let trades: Vec<BitmexTrade> = serde_json::from_str(&json_data).unwrap();
864
865        assert_eq!(trades.len(), 3);
866
867        // Test first trade
868        let trade1 = &trades[0];
869        assert_eq!(trade1.symbol, "XBTUSD");
870        assert_eq!(trade1.side, Some(BitmexSide::Buy));
871        assert_eq!(trade1.size, 100);
872        assert_eq!(trade1.price, 98950.0);
873
874        // Test third trade (Sell side)
875        let trade3 = &trades[2];
876        assert_eq!(trade3.side, Some(BitmexSide::Sell));
877        assert_eq!(trade3.size, 50);
878        assert_eq!(trade3.price, 98949.5);
879    }
880
881    #[rstest]
882    fn test_parse_wallet() {
883        let json_data = load_test_json("http_get_wallet.json");
884        let wallets: Vec<BitmexWallet> = serde_json::from_str(&json_data).unwrap();
885
886        assert_eq!(wallets.len(), 1);
887
888        let wallet = &wallets[0];
889        assert_eq!(wallet.account, 1234567);
890        assert_eq!(wallet.currency, "XBt");
891        assert_eq!(wallet.amount, Some(1000123456));
892        assert_eq!(wallet.delta_amount, Some(123456));
893    }
894
895    #[rstest]
896    fn test_parse_trade_bins() {
897        let json_data = load_test_json("http_get_trade_bins.json");
898        let bins: Vec<BitmexTradeBin> = serde_json::from_str(&json_data).unwrap();
899
900        assert_eq!(bins.len(), 3);
901
902        // Test first bin
903        let bin1 = &bins[0];
904        assert_eq!(bin1.symbol, "XBTUSD");
905        assert_eq!(bin1.open, Some(98900.0));
906        assert_eq!(bin1.high, Some(98980.5));
907        assert_eq!(bin1.low, Some(98890.0));
908        assert_eq!(bin1.close, Some(98950.0));
909        assert_eq!(bin1.volume, Some(150000));
910        assert_eq!(bin1.trades, Some(45));
911
912        // Test last bin
913        let bin3 = &bins[2];
914        assert_eq!(bin3.close, Some(98970.0));
915        assert_eq!(bin3.volume, Some(78000));
916    }
917
918    #[rstest]
919    fn test_parse_order_status_report() {
920        let symbol = Ustr::from("XBTUSD");
921
922        let order = BitmexOrder {
923            account: 123456,
924            symbol: Some(Ustr::from("XBTUSD")),
925            order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
926            cl_ord_id: Some(Ustr::from("client-123")),
927            cl_ord_link_id: None,
928            side: Some(BitmexSide::Buy),
929            ord_type: Some(BitmexOrderType::Limit),
930            time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
931            ord_status: Some(BitmexOrderStatus::New),
932            order_qty: Some(100),
933            cum_qty: Some(50),
934            price: Some(50000.0),
935            stop_px: Some(49000.0),
936            display_qty: None,
937            peg_offset_value: None,
938            peg_price_type: None,
939            currency: Some(Ustr::from("USD")),
940            settl_currency: Some(Ustr::from("XBt")),
941            exec_inst: Some(vec![
942                BitmexExecInstruction::ParticipateDoNotInitiate,
943                BitmexExecInstruction::ReduceOnly,
944            ]),
945            contingency_type: Some(BitmexContingencyType::OneCancelsTheOther),
946            ex_destination: None,
947            triggered: None,
948            working_indicator: Some(true),
949            ord_rej_reason: None,
950            leaves_qty: Some(50),
951            avg_px: None,
952            multi_leg_reporting_type: None,
953            text: None,
954            transact_time: Some(
955                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
956                    .unwrap()
957                    .with_timezone(&Utc),
958            ),
959            timestamp: Some(
960                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
961                    .unwrap()
962                    .with_timezone(&Utc),
963            ),
964        };
965
966        let instrument_id = parse_instrument_id(symbol);
967        let report =
968            parse_order_status_report(&order, instrument_id, 2, UnixNanos::from(1)).unwrap();
969
970        assert_eq!(report.account_id.to_string(), "BITMEX-123456");
971        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
972        assert_eq!(
973            report.venue_order_id.as_str(),
974            "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
975        );
976        assert_eq!(report.client_order_id.unwrap().as_str(), "client-123");
977        assert_eq!(report.quantity.as_f64(), 100.0);
978        assert_eq!(report.filled_qty.as_f64(), 50.0);
979        assert_eq!(report.price.unwrap().as_f64(), 50000.0);
980        assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
981        assert!(report.post_only);
982        assert!(report.reduce_only);
983    }
984
985    #[rstest]
986    fn test_parse_order_status_report_minimal() {
987        let symbol = Ustr::from("ETHUSD");
988        let order = BitmexOrder {
989            account: 0, // Use 0 for test account
990            symbol: Some(Ustr::from("ETHUSD")),
991            order_id: Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap(),
992            cl_ord_id: None,
993            cl_ord_link_id: None,
994            side: Some(BitmexSide::Sell),
995            ord_type: Some(BitmexOrderType::Market),
996            time_in_force: Some(BitmexTimeInForce::ImmediateOrCancel),
997            ord_status: Some(BitmexOrderStatus::Filled),
998            order_qty: Some(200),
999            cum_qty: Some(200),
1000            price: None,
1001            stop_px: None,
1002            display_qty: None,
1003            peg_offset_value: None,
1004            peg_price_type: None,
1005            currency: None,
1006            settl_currency: None,
1007            exec_inst: None,
1008            contingency_type: None,
1009            ex_destination: None,
1010            triggered: None,
1011            working_indicator: Some(false),
1012            ord_rej_reason: None,
1013            leaves_qty: Some(0),
1014            avg_px: None,
1015            multi_leg_reporting_type: None,
1016            text: None,
1017            transact_time: Some(
1018                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1019                    .unwrap()
1020                    .with_timezone(&Utc),
1021            ),
1022            timestamp: Some(
1023                DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
1024                    .unwrap()
1025                    .with_timezone(&Utc),
1026            ),
1027        };
1028
1029        let instrument_id = parse_instrument_id(symbol);
1030        let report =
1031            parse_order_status_report(&order, instrument_id, 2, UnixNanos::from(1)).unwrap();
1032
1033        assert_eq!(report.account_id.to_string(), "BITMEX-0");
1034        assert_eq!(report.instrument_id.to_string(), "ETHUSD.BITMEX");
1035        assert_eq!(
1036            report.venue_order_id.as_str(),
1037            "11111111-2222-3333-4444-555555555555"
1038        );
1039        assert!(report.client_order_id.is_none());
1040        assert_eq!(report.quantity.as_f64(), 200.0);
1041        assert_eq!(report.filled_qty.as_f64(), 200.0);
1042        assert!(report.price.is_none());
1043        assert!(report.trigger_price.is_none());
1044        assert!(!report.post_only);
1045        assert!(!report.reduce_only);
1046    }
1047
1048    #[rstest]
1049    fn test_parse_fill_report() {
1050        let exec = BitmexExecution {
1051            exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
1052            account: 654321,
1053            symbol: Some(Ustr::from("XBTUSD")),
1054            order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
1055            cl_ord_id: Some(Ustr::from("client-456")),
1056            side: Some(BitmexSide::Buy),
1057            last_qty: 50,
1058            last_px: 50100.5,
1059            commission: Some(0.00075),
1060            settl_currency: Some(Ustr::from("XBt")),
1061            last_liquidity_ind: Some(BitmexLiquidityIndicator::Taker),
1062            trd_match_id: Some(Uuid::parse_str("99999999-8888-7777-6666-555555555555").unwrap()),
1063            transact_time: Some(
1064                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1065                    .unwrap()
1066                    .with_timezone(&Utc),
1067            ),
1068            cl_ord_link_id: None,
1069            underlying_last_px: None,
1070            last_mkt: None,
1071            order_qty: Some(50),
1072            price: Some(50100.0),
1073            display_qty: None,
1074            stop_px: None,
1075            peg_offset_value: None,
1076            peg_price_type: None,
1077            currency: None,
1078            exec_type: BitmexExecType::Trade,
1079            ord_type: BitmexOrderType::Limit,
1080            time_in_force: BitmexTimeInForce::GoodTillCancel,
1081            exec_inst: None,
1082            contingency_type: None,
1083            ex_destination: None,
1084            ord_status: Some(BitmexOrderStatus::Filled),
1085            triggered: None,
1086            working_indicator: None,
1087            ord_rej_reason: None,
1088            leaves_qty: None,
1089            cum_qty: Some(50),
1090            avg_px: Some(50100.5),
1091            trade_publish_indicator: None,
1092            multi_leg_reporting_type: None,
1093            text: None,
1094            exec_cost: None,
1095            exec_comm: None,
1096            home_notional: None,
1097            foreign_notional: None,
1098            timestamp: None,
1099        };
1100
1101        let report = parse_fill_report(exec, 2, UnixNanos::from(1)).unwrap();
1102
1103        assert_eq!(report.account_id.to_string(), "BITMEX-654321");
1104        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
1105        assert_eq!(
1106            report.venue_order_id.as_str(),
1107            "a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6"
1108        );
1109        assert_eq!(
1110            report.trade_id.to_string(),
1111            "99999999-8888-7777-6666-555555555555"
1112        );
1113        assert_eq!(report.client_order_id.unwrap().as_str(), "client-456");
1114        assert_eq!(report.last_qty.as_f64(), 50.0);
1115        assert_eq!(report.last_px.as_f64(), 50100.5);
1116        assert_eq!(report.commission.as_f64(), 0.00075);
1117        assert_eq!(report.commission.currency.code.as_str(), "XBT");
1118        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1119    }
1120
1121    #[rstest]
1122    fn test_parse_fill_report_with_missing_trd_match_id() {
1123        let exec = BitmexExecution {
1124            exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
1125            account: 111111,
1126            symbol: Some(Ustr::from("ETHUSD")),
1127            order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
1128            cl_ord_id: None,
1129            side: Some(BitmexSide::Sell),
1130            last_qty: 100,
1131            last_px: 3000.0,
1132            commission: None,
1133            settl_currency: None,
1134            last_liquidity_ind: Some(BitmexLiquidityIndicator::Maker),
1135            trd_match_id: None, // Missing, should fall back to exec_id
1136            transact_time: Some(
1137                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1138                    .unwrap()
1139                    .with_timezone(&Utc),
1140            ),
1141            cl_ord_link_id: None,
1142            underlying_last_px: None,
1143            last_mkt: None,
1144            order_qty: Some(100),
1145            price: Some(3000.0),
1146            display_qty: None,
1147            stop_px: None,
1148            peg_offset_value: None,
1149            peg_price_type: None,
1150            currency: None,
1151            exec_type: BitmexExecType::Trade,
1152            ord_type: BitmexOrderType::Market,
1153            time_in_force: BitmexTimeInForce::ImmediateOrCancel,
1154            exec_inst: None,
1155            contingency_type: None,
1156            ex_destination: None,
1157            ord_status: Some(BitmexOrderStatus::Filled),
1158            triggered: None,
1159            working_indicator: None,
1160            ord_rej_reason: None,
1161            leaves_qty: None,
1162            cum_qty: Some(100),
1163            avg_px: Some(3000.0),
1164            trade_publish_indicator: None,
1165            multi_leg_reporting_type: None,
1166            text: None,
1167            exec_cost: None,
1168            exec_comm: None,
1169            home_notional: None,
1170            foreign_notional: None,
1171            timestamp: None,
1172        };
1173
1174        let report = parse_fill_report(exec, 2, UnixNanos::from(1)).unwrap();
1175
1176        assert_eq!(report.account_id.to_string(), "BITMEX-111111");
1177        assert_eq!(
1178            report.trade_id.to_string(),
1179            "f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6"
1180        );
1181        assert!(report.client_order_id.is_none());
1182        assert_eq!(report.commission.as_f64(), 0.0);
1183        assert_eq!(report.commission.currency.code.as_str(), "XBT");
1184        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1185    }
1186
1187    #[rstest]
1188    fn test_parse_position_report() {
1189        let position = BitmexPosition {
1190            account: 789012,
1191            symbol: Ustr::from("XBTUSD"),
1192            current_qty: Some(1000),
1193            timestamp: Some(
1194                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1195                    .unwrap()
1196                    .with_timezone(&Utc),
1197            ),
1198            currency: None,
1199            underlying: None,
1200            quote_currency: None,
1201            commission: None,
1202            init_margin_req: None,
1203            maint_margin_req: None,
1204            risk_limit: None,
1205            leverage: None,
1206            cross_margin: None,
1207            deleverage_percentile: None,
1208            rebalanced_pnl: None,
1209            prev_realised_pnl: None,
1210            prev_unrealised_pnl: None,
1211            prev_close_price: None,
1212            opening_timestamp: None,
1213            opening_qty: None,
1214            opening_cost: None,
1215            opening_comm: None,
1216            open_order_buy_qty: None,
1217            open_order_buy_cost: None,
1218            open_order_buy_premium: None,
1219            open_order_sell_qty: None,
1220            open_order_sell_cost: None,
1221            open_order_sell_premium: None,
1222            exec_buy_qty: None,
1223            exec_buy_cost: None,
1224            exec_sell_qty: None,
1225            exec_sell_cost: None,
1226            exec_qty: None,
1227            exec_cost: None,
1228            exec_comm: None,
1229            current_timestamp: None,
1230            current_cost: None,
1231            current_comm: None,
1232            realised_cost: None,
1233            unrealised_cost: None,
1234            gross_open_cost: None,
1235            gross_open_premium: None,
1236            gross_exec_cost: None,
1237            is_open: Some(true),
1238            mark_price: None,
1239            mark_value: None,
1240            risk_value: None,
1241            home_notional: None,
1242            foreign_notional: None,
1243            pos_state: None,
1244            pos_cost: None,
1245            pos_cost2: None,
1246            pos_cross: None,
1247            pos_init: None,
1248            pos_comm: None,
1249            pos_loss: None,
1250            pos_margin: None,
1251            pos_maint: None,
1252            pos_allowance: None,
1253            taxable_margin: None,
1254            init_margin: None,
1255            maint_margin: None,
1256            session_margin: None,
1257            target_excess_margin: None,
1258            var_margin: None,
1259            realised_gross_pnl: None,
1260            realised_tax: None,
1261            realised_pnl: None,
1262            unrealised_gross_pnl: None,
1263            long_bankrupt: None,
1264            short_bankrupt: None,
1265            tax_base: None,
1266            indicative_tax_rate: None,
1267            indicative_tax: None,
1268            unrealised_tax: None,
1269            unrealised_pnl: None,
1270            unrealised_pnl_pcnt: None,
1271            unrealised_roe_pcnt: None,
1272            avg_cost_price: None,
1273            avg_entry_price: None,
1274            break_even_price: None,
1275            margin_call_price: None,
1276            liquidation_price: None,
1277            bankrupt_price: None,
1278            last_price: None,
1279            last_value: None,
1280        };
1281
1282        let report = parse_position_report(position, UnixNanos::from(1)).unwrap();
1283
1284        assert_eq!(report.account_id.to_string(), "BITMEX-789012");
1285        assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
1286        assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
1287        assert_eq!(report.quantity.as_f64(), 1000.0);
1288    }
1289
1290    #[rstest]
1291    fn test_parse_position_report_short() {
1292        let position = BitmexPosition {
1293            account: 789012,
1294            symbol: Ustr::from("ETHUSD"),
1295            current_qty: Some(-500),
1296            timestamp: Some(
1297                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1298                    .unwrap()
1299                    .with_timezone(&Utc),
1300            ),
1301            currency: None,
1302            underlying: None,
1303            quote_currency: None,
1304            commission: None,
1305            init_margin_req: None,
1306            maint_margin_req: None,
1307            risk_limit: None,
1308            leverage: None,
1309            cross_margin: None,
1310            deleverage_percentile: None,
1311            rebalanced_pnl: None,
1312            prev_realised_pnl: None,
1313            prev_unrealised_pnl: None,
1314            prev_close_price: None,
1315            opening_timestamp: None,
1316            opening_qty: None,
1317            opening_cost: None,
1318            opening_comm: None,
1319            open_order_buy_qty: None,
1320            open_order_buy_cost: None,
1321            open_order_buy_premium: None,
1322            open_order_sell_qty: None,
1323            open_order_sell_cost: None,
1324            open_order_sell_premium: None,
1325            exec_buy_qty: None,
1326            exec_buy_cost: None,
1327            exec_sell_qty: None,
1328            exec_sell_cost: None,
1329            exec_qty: None,
1330            exec_cost: None,
1331            exec_comm: None,
1332            current_timestamp: None,
1333            current_cost: None,
1334            current_comm: None,
1335            realised_cost: None,
1336            unrealised_cost: None,
1337            gross_open_cost: None,
1338            gross_open_premium: None,
1339            gross_exec_cost: None,
1340            is_open: Some(true),
1341            mark_price: None,
1342            mark_value: None,
1343            risk_value: None,
1344            home_notional: None,
1345            foreign_notional: None,
1346            pos_state: None,
1347            pos_cost: None,
1348            pos_cost2: None,
1349            pos_cross: None,
1350            pos_init: None,
1351            pos_comm: None,
1352            pos_loss: None,
1353            pos_margin: None,
1354            pos_maint: None,
1355            pos_allowance: None,
1356            taxable_margin: None,
1357            init_margin: None,
1358            maint_margin: None,
1359            session_margin: None,
1360            target_excess_margin: None,
1361            var_margin: None,
1362            realised_gross_pnl: None,
1363            realised_tax: None,
1364            realised_pnl: None,
1365            unrealised_gross_pnl: None,
1366            long_bankrupt: None,
1367            short_bankrupt: None,
1368            tax_base: None,
1369            indicative_tax_rate: None,
1370            indicative_tax: None,
1371            unrealised_tax: None,
1372            unrealised_pnl: None,
1373            unrealised_pnl_pcnt: None,
1374            unrealised_roe_pcnt: None,
1375            avg_cost_price: None,
1376            avg_entry_price: None,
1377            break_even_price: None,
1378            margin_call_price: None,
1379            liquidation_price: None,
1380            bankrupt_price: None,
1381            last_price: None,
1382            last_value: None,
1383        };
1384
1385        let report = parse_position_report(position, UnixNanos::from(1)).unwrap();
1386
1387        assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
1388        assert_eq!(report.quantity.as_f64(), 500.0); // Should be absolute value
1389    }
1390
1391    #[rstest]
1392    fn test_parse_position_report_flat() {
1393        let position = BitmexPosition {
1394            account: 789012,
1395            symbol: Ustr::from("SOLUSD"),
1396            current_qty: Some(0),
1397            timestamp: Some(
1398                DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1399                    .unwrap()
1400                    .with_timezone(&Utc),
1401            ),
1402            currency: None,
1403            underlying: None,
1404            quote_currency: None,
1405            commission: None,
1406            init_margin_req: None,
1407            maint_margin_req: None,
1408            risk_limit: None,
1409            leverage: None,
1410            cross_margin: None,
1411            deleverage_percentile: None,
1412            rebalanced_pnl: None,
1413            prev_realised_pnl: None,
1414            prev_unrealised_pnl: None,
1415            prev_close_price: None,
1416            opening_timestamp: None,
1417            opening_qty: None,
1418            opening_cost: None,
1419            opening_comm: None,
1420            open_order_buy_qty: None,
1421            open_order_buy_cost: None,
1422            open_order_buy_premium: None,
1423            open_order_sell_qty: None,
1424            open_order_sell_cost: None,
1425            open_order_sell_premium: None,
1426            exec_buy_qty: None,
1427            exec_buy_cost: None,
1428            exec_sell_qty: None,
1429            exec_sell_cost: None,
1430            exec_qty: None,
1431            exec_cost: None,
1432            exec_comm: None,
1433            current_timestamp: None,
1434            current_cost: None,
1435            current_comm: None,
1436            realised_cost: None,
1437            unrealised_cost: None,
1438            gross_open_cost: None,
1439            gross_open_premium: None,
1440            gross_exec_cost: None,
1441            is_open: Some(true),
1442            mark_price: None,
1443            mark_value: None,
1444            risk_value: None,
1445            home_notional: None,
1446            foreign_notional: None,
1447            pos_state: None,
1448            pos_cost: None,
1449            pos_cost2: None,
1450            pos_cross: None,
1451            pos_init: None,
1452            pos_comm: None,
1453            pos_loss: None,
1454            pos_margin: None,
1455            pos_maint: None,
1456            pos_allowance: None,
1457            taxable_margin: None,
1458            init_margin: None,
1459            maint_margin: None,
1460            session_margin: None,
1461            target_excess_margin: None,
1462            var_margin: None,
1463            realised_gross_pnl: None,
1464            realised_tax: None,
1465            realised_pnl: None,
1466            unrealised_gross_pnl: None,
1467            long_bankrupt: None,
1468            short_bankrupt: None,
1469            tax_base: None,
1470            indicative_tax_rate: None,
1471            indicative_tax: None,
1472            unrealised_tax: None,
1473            unrealised_pnl: None,
1474            unrealised_pnl_pcnt: None,
1475            unrealised_roe_pcnt: None,
1476            avg_cost_price: None,
1477            avg_entry_price: None,
1478            break_even_price: None,
1479            margin_call_price: None,
1480            liquidation_price: None,
1481            bankrupt_price: None,
1482            last_price: None,
1483            last_value: None,
1484        };
1485
1486        let report = parse_position_report(position, UnixNanos::from(1)).unwrap();
1487
1488        assert_eq!(report.position_side.as_position_side(), PositionSide::Flat);
1489        assert_eq!(report.quantity.as_f64(), 0.0);
1490    }
1491
1492    // ========================================================================
1493    // Test Fixtures for Instrument Parsing
1494    // ========================================================================
1495
1496    fn create_test_spot_instrument() -> BitmexInstrument {
1497        BitmexInstrument {
1498            symbol: Ustr::from("XBTUSD"),
1499            root_symbol: Ustr::from("XBT"),
1500            state: BitmexInstrumentState::Open,
1501            instrument_type: BitmexInstrumentType::Spot,
1502            listing: DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
1503                .unwrap()
1504                .with_timezone(&Utc),
1505            front: Some(
1506                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
1507                    .unwrap()
1508                    .with_timezone(&Utc),
1509            ),
1510            expiry: None,
1511            settle: None,
1512            listed_settle: None,
1513            position_currency: Some(Ustr::from("USD")),
1514            underlying: Ustr::from("XBT"),
1515            quote_currency: Ustr::from("USD"),
1516            underlying_symbol: Some(Ustr::from("XBT=")),
1517            reference: Some(Ustr::from("BMEX")),
1518            reference_symbol: Some(Ustr::from(".BXBT")),
1519            lot_size: Some(1.0),
1520            tick_size: 0.01,
1521            multiplier: 1.0,
1522            settl_currency: Some(Ustr::from("USD")),
1523            is_quanto: false,
1524            is_inverse: false,
1525            maker_fee: Some(-0.00025),
1526            taker_fee: Some(0.00075),
1527            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
1528                .unwrap()
1529                .with_timezone(&Utc),
1530            // Set other fields to reasonable defaults
1531            max_order_qty: Some(10000000.0),
1532            max_price: Some(1000000.0),
1533            settlement_fee: Some(0.0),
1534            mark_price: Some(50500.0),
1535            last_price: Some(50500.0),
1536            bid_price: Some(50499.5),
1537            ask_price: Some(50500.5),
1538            open_interest: Some(0.0),
1539            open_value: Some(0.0),
1540            total_volume: Some(1000000.0),
1541            volume: Some(50000.0),
1542            volume_24h: Some(75000.0),
1543            total_turnover: Some(150000000.0),
1544            turnover: Some(5000000.0),
1545            turnover_24h: Some(7500000.0),
1546            has_liquidity: Some(true),
1547            // Set remaining fields to None/defaults
1548            calc_interval: None,
1549            publish_interval: None,
1550            publish_time: None,
1551            underlying_to_position_multiplier: Some(1.0),
1552            underlying_to_settle_multiplier: None,
1553            quote_to_settle_multiplier: Some(1.0),
1554            init_margin: Some(0.1),
1555            maint_margin: Some(0.05),
1556            risk_limit: Some(20000000000.0),
1557            risk_step: Some(10000000000.0),
1558            limit: None,
1559            taxed: Some(true),
1560            deleverage: Some(true),
1561            funding_base_symbol: None,
1562            funding_quote_symbol: None,
1563            funding_premium_symbol: None,
1564            funding_timestamp: None,
1565            funding_interval: None,
1566            funding_rate: None,
1567            indicative_funding_rate: None,
1568            rebalance_timestamp: None,
1569            rebalance_interval: None,
1570            prev_close_price: Some(50000.0),
1571            limit_down_price: None,
1572            limit_up_price: None,
1573            prev_total_turnover: Some(100000000.0),
1574            home_notional_24h: Some(1.5),
1575            foreign_notional_24h: Some(75000.0),
1576            prev_price_24h: Some(49500.0),
1577            vwap: Some(50100.0),
1578            high_price: Some(51000.0),
1579            low_price: Some(49000.0),
1580            last_price_protected: Some(50500.0),
1581            last_tick_direction: Some(BitmexTickDirection::PlusTick),
1582            last_change_pcnt: Some(0.0202),
1583            mid_price: Some(50500.0),
1584            impact_bid_price: Some(50490.0),
1585            impact_mid_price: Some(50495.0),
1586            impact_ask_price: Some(50500.0),
1587            fair_method: None,
1588            fair_basis_rate: None,
1589            fair_basis: None,
1590            fair_price: None,
1591            mark_method: Some(BitmexMarkMethod::LastPrice),
1592            indicative_settle_price: None,
1593            settled_price_adjustment_rate: None,
1594            settled_price: None,
1595            instant_pnl: false,
1596            min_tick: None,
1597            funding_base_rate: None,
1598            funding_quote_rate: None,
1599        }
1600    }
1601
1602    fn create_test_perpetual_instrument() -> BitmexInstrument {
1603        BitmexInstrument {
1604            symbol: Ustr::from("XBTUSD"),
1605            root_symbol: Ustr::from("XBT"),
1606            state: BitmexInstrumentState::Open,
1607            instrument_type: BitmexInstrumentType::PerpetualContract,
1608            listing: DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
1609                .unwrap()
1610                .with_timezone(&Utc),
1611            front: Some(
1612                DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
1613                    .unwrap()
1614                    .with_timezone(&Utc),
1615            ),
1616            expiry: None,
1617            settle: None,
1618            listed_settle: None,
1619            position_currency: Some(Ustr::from("USD")),
1620            underlying: Ustr::from("XBT"),
1621            quote_currency: Ustr::from("USD"),
1622            underlying_symbol: Some(Ustr::from("XBT=")),
1623            reference: Some(Ustr::from("BMEX")),
1624            reference_symbol: Some(Ustr::from(".BXBT")),
1625            lot_size: Some(1.0),
1626            tick_size: 0.5,
1627            multiplier: -1.0,
1628            settl_currency: Some(Ustr::from("XBT")),
1629            is_quanto: false,
1630            is_inverse: true,
1631            maker_fee: Some(-0.00025),
1632            taker_fee: Some(0.00075),
1633            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
1634                .unwrap()
1635                .with_timezone(&Utc),
1636            // Set other fields
1637            max_order_qty: Some(10000000.0),
1638            max_price: Some(1000000.0),
1639            settlement_fee: Some(0.0),
1640            mark_price: Some(50500.01),
1641            last_price: Some(50500.0),
1642            bid_price: Some(50499.5),
1643            ask_price: Some(50500.5),
1644            open_interest: Some(500000000.0),
1645            open_value: Some(990099009900.0),
1646            total_volume: Some(12345678900000.0),
1647            volume: Some(5000000.0),
1648            volume_24h: Some(75000000.0),
1649            total_turnover: Some(150000000000000.0),
1650            turnover: Some(5000000000.0),
1651            turnover_24h: Some(7500000000.0),
1652            has_liquidity: Some(true),
1653            // Perpetual specific fields
1654            funding_base_symbol: Some(Ustr::from(".XBTBON8H")),
1655            funding_quote_symbol: Some(Ustr::from(".USDBON8H")),
1656            funding_premium_symbol: Some(Ustr::from(".XBTUSDPI8H")),
1657            funding_timestamp: Some(
1658                DateTime::parse_from_rfc3339("2024-01-01T08:00:00.000Z")
1659                    .unwrap()
1660                    .with_timezone(&Utc),
1661            ),
1662            funding_interval: Some(
1663                DateTime::parse_from_rfc3339("2000-01-01T08:00:00.000Z")
1664                    .unwrap()
1665                    .with_timezone(&Utc),
1666            ),
1667            funding_rate: Some(0.0001),
1668            indicative_funding_rate: Some(0.0001),
1669            funding_base_rate: Some(0.01),
1670            funding_quote_rate: Some(-0.01),
1671            // Other fields
1672            calc_interval: None,
1673            publish_interval: None,
1674            publish_time: None,
1675            underlying_to_position_multiplier: Some(1.0),
1676            underlying_to_settle_multiplier: None,
1677            quote_to_settle_multiplier: Some(0.00000001),
1678            init_margin: Some(0.01),
1679            maint_margin: Some(0.005),
1680            risk_limit: Some(20000000000.0),
1681            risk_step: Some(10000000000.0),
1682            limit: None,
1683            taxed: Some(true),
1684            deleverage: Some(true),
1685            rebalance_timestamp: None,
1686            rebalance_interval: None,
1687            prev_close_price: Some(50000.0),
1688            limit_down_price: None,
1689            limit_up_price: None,
1690            prev_total_turnover: Some(100000000000000.0),
1691            home_notional_24h: Some(1500.0),
1692            foreign_notional_24h: Some(75000000.0),
1693            prev_price_24h: Some(49500.0),
1694            vwap: Some(50100.0),
1695            high_price: Some(51000.0),
1696            low_price: Some(49000.0),
1697            last_price_protected: Some(50500.0),
1698            last_tick_direction: Some(BitmexTickDirection::PlusTick),
1699            last_change_pcnt: Some(0.0202),
1700            mid_price: Some(50500.0),
1701            impact_bid_price: Some(50490.0),
1702            impact_mid_price: Some(50495.0),
1703            impact_ask_price: Some(50500.0),
1704            fair_method: Some(BitmexFairMethod::FundingRate),
1705            fair_basis_rate: Some(0.1095),
1706            fair_basis: Some(0.01),
1707            fair_price: Some(50500.01),
1708            mark_method: Some(BitmexMarkMethod::FairPrice),
1709            indicative_settle_price: Some(50500.0),
1710            settled_price_adjustment_rate: None,
1711            settled_price: None,
1712            instant_pnl: false,
1713            min_tick: None,
1714        }
1715    }
1716
1717    fn create_test_futures_instrument() -> BitmexInstrument {
1718        BitmexInstrument {
1719            symbol: Ustr::from("XBTH25"),
1720            root_symbol: Ustr::from("XBT"),
1721            state: BitmexInstrumentState::Open,
1722            instrument_type: BitmexInstrumentType::Futures,
1723            listing: DateTime::parse_from_rfc3339("2024-09-27T12:00:00.000Z")
1724                .unwrap()
1725                .with_timezone(&Utc),
1726            front: Some(
1727                DateTime::parse_from_rfc3339("2024-12-27T12:00:00.000Z")
1728                    .unwrap()
1729                    .with_timezone(&Utc),
1730            ),
1731            expiry: Some(
1732                DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
1733                    .unwrap()
1734                    .with_timezone(&Utc),
1735            ),
1736            settle: Some(
1737                DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
1738                    .unwrap()
1739                    .with_timezone(&Utc),
1740            ),
1741            listed_settle: None,
1742            position_currency: Some(Ustr::from("USD")),
1743            underlying: Ustr::from("XBT"),
1744            quote_currency: Ustr::from("USD"),
1745            underlying_symbol: Some(Ustr::from("XBT=")),
1746            reference: Some(Ustr::from("BMEX")),
1747            reference_symbol: Some(Ustr::from(".BXBT30M")),
1748            lot_size: Some(1.0),
1749            tick_size: 0.5,
1750            multiplier: -1.0,
1751            settl_currency: Some(Ustr::from("XBT")),
1752            is_quanto: false,
1753            is_inverse: true,
1754            maker_fee: Some(-0.00025),
1755            taker_fee: Some(0.00075),
1756            settlement_fee: Some(0.0005),
1757            timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
1758                .unwrap()
1759                .with_timezone(&Utc),
1760            // Set other fields
1761            max_order_qty: Some(10000000.0),
1762            max_price: Some(1000000.0),
1763            mark_price: Some(55500.0),
1764            last_price: Some(55500.0),
1765            bid_price: Some(55499.5),
1766            ask_price: Some(55500.5),
1767            open_interest: Some(50000000.0),
1768            open_value: Some(90090090090.0),
1769            total_volume: Some(1000000000.0),
1770            volume: Some(500000.0),
1771            volume_24h: Some(7500000.0),
1772            total_turnover: Some(15000000000000.0),
1773            turnover: Some(500000000.0),
1774            turnover_24h: Some(750000000.0),
1775            has_liquidity: Some(true),
1776            // Futures specific fields
1777            funding_base_symbol: None,
1778            funding_quote_symbol: None,
1779            funding_premium_symbol: None,
1780            funding_timestamp: None,
1781            funding_interval: None,
1782            funding_rate: None,
1783            indicative_funding_rate: None,
1784            funding_base_rate: None,
1785            funding_quote_rate: None,
1786            // Other fields
1787            calc_interval: None,
1788            publish_interval: None,
1789            publish_time: None,
1790            underlying_to_position_multiplier: Some(1.0),
1791            underlying_to_settle_multiplier: None,
1792            quote_to_settle_multiplier: Some(0.00000001),
1793            init_margin: Some(0.02),
1794            maint_margin: Some(0.01),
1795            risk_limit: Some(20000000000.0),
1796            risk_step: Some(10000000000.0),
1797            limit: None,
1798            taxed: Some(true),
1799            deleverage: Some(true),
1800            rebalance_timestamp: None,
1801            rebalance_interval: None,
1802            prev_close_price: Some(55000.0),
1803            limit_down_price: None,
1804            limit_up_price: None,
1805            prev_total_turnover: Some(10000000000000.0),
1806            home_notional_24h: Some(150.0),
1807            foreign_notional_24h: Some(7500000.0),
1808            prev_price_24h: Some(54500.0),
1809            vwap: Some(55100.0),
1810            high_price: Some(56000.0),
1811            low_price: Some(54000.0),
1812            last_price_protected: Some(55500.0),
1813            last_tick_direction: Some(BitmexTickDirection::PlusTick),
1814            last_change_pcnt: Some(0.0183),
1815            mid_price: Some(55500.0),
1816            impact_bid_price: Some(55490.0),
1817            impact_mid_price: Some(55495.0),
1818            impact_ask_price: Some(55500.0),
1819            fair_method: Some(BitmexFairMethod::ImpactMidPrice),
1820            fair_basis_rate: Some(1.8264),
1821            fair_basis: Some(1000.0),
1822            fair_price: Some(55500.0),
1823            mark_method: Some(BitmexMarkMethod::FairPrice),
1824            indicative_settle_price: Some(55500.0),
1825            settled_price_adjustment_rate: None,
1826            settled_price: None,
1827            instant_pnl: false,
1828            min_tick: None,
1829        }
1830    }
1831
1832    // ========================================================================
1833    // Instrument Parsing Tests
1834    // ========================================================================
1835
1836    #[rstest]
1837    fn test_parse_spot_instrument() {
1838        let instrument = create_test_spot_instrument();
1839        let ts_init = UnixNanos::default();
1840        let result = parse_spot_instrument(&instrument, ts_init).unwrap();
1841
1842        // Check it's a CurrencyPair variant
1843        match result {
1844            nautilus_model::instruments::InstrumentAny::CurrencyPair(spot) => {
1845                assert_eq!(spot.id.symbol.as_str(), "XBTUSD");
1846                assert_eq!(spot.id.venue.as_str(), "BITMEX");
1847                assert_eq!(spot.raw_symbol.as_str(), "XBTUSD");
1848                assert_eq!(spot.price_precision, 2);
1849                assert_eq!(spot.size_precision, 0);
1850                assert_eq!(spot.price_increment.as_f64(), 0.01);
1851                assert_eq!(spot.size_increment.as_f64(), 1.0);
1852                assert_eq!(spot.maker_fee.to_f64().unwrap(), -0.00025);
1853                assert_eq!(spot.taker_fee.to_f64().unwrap(), 0.00075);
1854            }
1855            _ => panic!("Expected CurrencyPair variant"),
1856        }
1857    }
1858
1859    #[rstest]
1860    fn test_parse_perpetual_instrument() {
1861        let instrument = create_test_perpetual_instrument();
1862        let ts_init = UnixNanos::default();
1863        let result = parse_perpetual_instrument(&instrument, ts_init).unwrap();
1864
1865        // Check it's a CryptoPerpetual variant
1866        match result {
1867            nautilus_model::instruments::InstrumentAny::CryptoPerpetual(perp) => {
1868                assert_eq!(perp.id.symbol.as_str(), "XBTUSD");
1869                assert_eq!(perp.id.venue.as_str(), "BITMEX");
1870                assert_eq!(perp.raw_symbol.as_str(), "XBTUSD");
1871                assert_eq!(perp.price_precision, 1);
1872                assert_eq!(perp.size_precision, 0);
1873                assert_eq!(perp.price_increment.as_f64(), 0.5);
1874                assert_eq!(perp.size_increment.as_f64(), 1.0);
1875                assert_eq!(perp.maker_fee.to_f64().unwrap(), -0.00025);
1876                assert_eq!(perp.taker_fee.to_f64().unwrap(), 0.00075);
1877                assert!(perp.is_inverse);
1878            }
1879            _ => panic!("Expected CryptoPerpetual variant"),
1880        }
1881    }
1882
1883    #[rstest]
1884    fn test_parse_futures_instrument() {
1885        let instrument = create_test_futures_instrument();
1886        let ts_init = UnixNanos::default();
1887        let result = parse_futures_instrument(&instrument, ts_init).unwrap();
1888
1889        // Check it's a CryptoFuture variant
1890        match result {
1891            nautilus_model::instruments::InstrumentAny::CryptoFuture(instrument) => {
1892                assert_eq!(instrument.id.symbol.as_str(), "XBTH25");
1893                assert_eq!(instrument.id.venue.as_str(), "BITMEX");
1894                assert_eq!(instrument.raw_symbol.as_str(), "XBTH25");
1895                assert_eq!(instrument.underlying.code.as_str(), "XBT");
1896                assert_eq!(instrument.price_precision, 1);
1897                assert_eq!(instrument.size_precision, 0);
1898                assert_eq!(instrument.price_increment.as_f64(), 0.5);
1899                assert_eq!(instrument.size_increment.as_f64(), 1.0);
1900                assert_eq!(instrument.maker_fee.to_f64().unwrap(), -0.00025);
1901                assert_eq!(instrument.taker_fee.to_f64().unwrap(), 0.00075);
1902                assert!(instrument.is_inverse);
1903                // Check expiration timestamp instead of expiry_date
1904                // The futures contract expires on 2025-03-28
1905                assert!(instrument.expiration_ns.as_u64() > 0);
1906            }
1907            _ => panic!("Expected CryptoFuture variant"),
1908        }
1909    }
1910}