use std::str::FromStr;
use dashmap::DashMap;
use nautilus_core::{UnixNanos, uuid::UUID4};
use nautilus_model::{
data::{Bar, BarType, TradeTick},
enums::{ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType},
identifiers::{AccountId, ClientOrderId, OrderListId, Symbol, TradeId, VenueOrderId},
instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
reports::{FillReport, OrderStatusReport, PositionStatusReport},
types::{Currency, Money, Price, Quantity, fixed::FIXED_PRECISION},
};
use rust_decimal::Decimal;
use ustr::Ustr;
use uuid::Uuid;
use super::models::{
BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTrade, BitmexTradeBin,
};
use crate::common::{
enums::{
BitmexExecInstruction, BitmexExecType, BitmexInstrumentState, BitmexInstrumentType,
BitmexOrderType, BitmexPegPriceType,
},
parse::{
clean_reason, convert_contract_quantity, derive_contract_decimal_and_increment,
extract_trigger_type, map_bitmex_currency, normalize_trade_bin_prices,
normalize_trade_bin_volume, parse_aggressor_side, parse_contracts_quantity,
parse_instrument_id, parse_liquidity_side, parse_optional_datetime_to_unix_nanos,
parse_position_side, parse_signed_contracts_quantity,
},
};
#[derive(Debug)]
pub enum InstrumentParseResult {
Ok(Box<InstrumentAny>),
Unsupported {
symbol: String,
instrument_type: BitmexInstrumentType,
},
Inactive {
symbol: String,
state: BitmexInstrumentState,
},
Failed {
symbol: String,
instrument_type: BitmexInstrumentType,
error: String,
},
}
fn get_position_multiplier(definition: &BitmexInstrument) -> Option<f64> {
if definition.is_inverse {
definition
.underlying_to_settle_multiplier
.or(definition.underlying_to_position_multiplier)
} else {
definition.underlying_to_position_multiplier
}
}
#[must_use]
pub fn parse_instrument_any(
instrument: &BitmexInstrument,
ts_init: UnixNanos,
) -> InstrumentParseResult {
let symbol = instrument.symbol.to_string();
let instrument_type = instrument.instrument_type;
match instrument.state {
BitmexInstrumentState::Open | BitmexInstrumentState::Closed => {}
state @ (BitmexInstrumentState::Unlisted
| BitmexInstrumentState::Settled
| BitmexInstrumentState::Delisted) => {
return InstrumentParseResult::Inactive { symbol, state };
}
}
match instrument.instrument_type {
BitmexInstrumentType::Spot => match parse_spot_instrument(instrument, ts_init) {
Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
Err(e) => InstrumentParseResult::Failed {
symbol,
instrument_type,
error: e.to_string(),
},
},
BitmexInstrumentType::PerpetualContract | BitmexInstrumentType::PerpetualContractFx => {
match parse_perpetual_instrument(instrument, ts_init) {
Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
Err(e) => InstrumentParseResult::Failed {
symbol,
instrument_type,
error: e.to_string(),
},
}
}
BitmexInstrumentType::Futures => match parse_futures_instrument(instrument, ts_init) {
Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
Err(e) => InstrumentParseResult::Failed {
symbol,
instrument_type,
error: e.to_string(),
},
},
BitmexInstrumentType::PredictionMarket => {
match parse_futures_instrument(instrument, ts_init) {
Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
Err(e) => InstrumentParseResult::Failed {
symbol,
instrument_type,
error: e.to_string(),
},
}
}
BitmexInstrumentType::BasketIndex
| BitmexInstrumentType::CryptoIndex
| BitmexInstrumentType::FxIndex
| BitmexInstrumentType::LendingIndex
| BitmexInstrumentType::VolatilityIndex
| BitmexInstrumentType::StockIndex
| BitmexInstrumentType::YieldIndex => {
match parse_index_instrument(instrument, ts_init) {
Ok(inst) => InstrumentParseResult::Ok(Box::new(inst)),
Err(e) => InstrumentParseResult::Failed {
symbol,
instrument_type,
error: e.to_string(),
},
}
}
BitmexInstrumentType::StockPerpetual
| BitmexInstrumentType::CallOption
| BitmexInstrumentType::PutOption
| BitmexInstrumentType::SwapRate
| BitmexInstrumentType::ReferenceBasket
| BitmexInstrumentType::LegacyFutures
| BitmexInstrumentType::LegacyFuturesN
| BitmexInstrumentType::FuturesSpreads => InstrumentParseResult::Unsupported {
symbol,
instrument_type,
},
}
}
pub fn parse_index_instrument(
definition: &BitmexInstrument,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let instrument_id = parse_instrument_id(definition.symbol);
let raw_symbol = Symbol::new(definition.symbol);
let base_currency = Currency::USD();
let quote_currency = Currency::USD();
let settlement_currency = Currency::USD();
let price_increment = Price::from(definition.tick_size.to_string());
let size_increment = Quantity::from(1);
Ok(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
settlement_currency,
false, price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
None, None, None, None, None, None, None, None, None, None, None, None, None, ts_init,
ts_init,
)))
}
pub fn parse_spot_instrument(
definition: &BitmexInstrument,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let instrument_id = parse_instrument_id(definition.symbol);
let raw_symbol = Symbol::new(definition.symbol);
let base_currency = get_currency(&definition.underlying.to_uppercase());
let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
let price_increment = Price::from(definition.tick_size.to_string());
let max_scale = FIXED_PRECISION as u32;
let (contract_decimal, size_increment) =
derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
let min_quantity = convert_contract_quantity(
definition.lot_size,
contract_decimal,
max_scale,
"minimum quantity",
)?;
let taker_fee = definition
.taker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let maker_fee = definition
.maker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let margin_init = definition
.init_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let margin_maint = definition
.maint_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let lot_size =
convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
let max_quantity = convert_contract_quantity(
definition.max_order_qty,
contract_decimal,
max_scale,
"max quantity",
)?;
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = definition
.max_price
.map(|price| Price::from(price.to_string()));
let min_price = definition
.min_price
.map(|price| Price::from(price.to_string()));
let ts_event = UnixNanos::from(definition.timestamp);
let instrument = CurrencyPair::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
None, lot_size,
max_quantity,
min_quantity,
max_notional,
min_notional,
max_price,
min_price,
Some(margin_init),
Some(margin_maint),
Some(maker_fee),
Some(taker_fee),
None, ts_event,
ts_init,
);
Ok(InstrumentAny::CurrencyPair(instrument))
}
pub fn parse_perpetual_instrument(
definition: &BitmexInstrument,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let instrument_id = parse_instrument_id(definition.symbol);
let raw_symbol = Symbol::new(definition.symbol);
let base_currency = get_currency(&definition.underlying.to_uppercase());
let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
let settlement_currency = get_currency(&definition.settl_currency.as_ref().map_or_else(
|| definition.quote_currency.to_uppercase(),
|s| s.to_uppercase(),
));
let is_inverse = definition.is_inverse;
let price_increment = Price::from(definition.tick_size.to_string());
let max_scale = FIXED_PRECISION as u32;
let (contract_decimal, size_increment) =
derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
let lot_size =
convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
let taker_fee = definition
.taker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let maker_fee = definition
.maker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let margin_init = definition
.init_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let margin_maint = definition
.maint_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
let max_quantity = convert_contract_quantity(
definition.max_order_qty,
contract_decimal,
max_scale,
"max quantity",
)?;
let min_quantity = lot_size;
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = definition
.max_price
.map(|price| Price::from(price.to_string()));
let min_price = definition
.min_price
.map(|price| Price::from(price.to_string()));
let ts_event = UnixNanos::from(definition.timestamp);
let instrument = CryptoPerpetual::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
settlement_currency,
is_inverse,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
multiplier,
lot_size,
max_quantity,
min_quantity,
max_notional,
min_notional,
max_price,
min_price,
Some(margin_init),
Some(margin_maint),
Some(maker_fee),
Some(taker_fee),
None, ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoPerpetual(instrument))
}
pub fn parse_futures_instrument(
definition: &BitmexInstrument,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let instrument_id = parse_instrument_id(definition.symbol);
let raw_symbol = Symbol::new(definition.symbol);
let underlying = get_currency(&definition.underlying.to_uppercase());
let quote_currency = get_currency(&definition.quote_currency.to_uppercase());
let settlement_currency = get_currency(&definition.settl_currency.as_ref().map_or_else(
|| definition.quote_currency.to_uppercase(),
|s| s.to_uppercase(),
));
let is_inverse = definition.is_inverse;
let ts_event = UnixNanos::from(definition.timestamp);
let activation_ns = definition
.listing
.as_ref()
.map_or(ts_event, |dt| UnixNanos::from(*dt));
let expiration_ns = parse_optional_datetime_to_unix_nanos(&definition.expiry, "expiry");
let price_increment = Price::from(definition.tick_size.to_string());
let max_scale = FIXED_PRECISION as u32;
let (contract_decimal, size_increment) =
derive_contract_decimal_and_increment(get_position_multiplier(definition), max_scale)?;
let lot_size =
convert_contract_quantity(definition.lot_size, contract_decimal, max_scale, "lot size")?;
let taker_fee = definition
.taker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let maker_fee = definition
.maker_fee
.and_then(|fee| Decimal::try_from(fee).ok())
.unwrap_or(Decimal::ZERO);
let margin_init = definition
.init_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let margin_maint = definition
.maint_margin
.as_ref()
.and_then(|margin| Decimal::try_from(*margin).ok())
.unwrap_or(Decimal::ZERO);
let multiplier = Some(Quantity::new_checked(definition.multiplier.abs(), 0)?);
let max_quantity = convert_contract_quantity(
definition.max_order_qty,
contract_decimal,
max_scale,
"max quantity",
)?;
let min_quantity = lot_size;
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = definition
.max_price
.map(|price| Price::from(price.to_string()));
let min_price = definition
.min_price
.map(|price| Price::from(price.to_string()));
let instrument = CryptoFuture::new(
instrument_id,
raw_symbol,
underlying,
quote_currency,
settlement_currency,
is_inverse,
activation_ns,
expiration_ns,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
multiplier,
lot_size,
max_quantity,
min_quantity,
max_notional,
min_notional,
max_price,
min_price,
Some(margin_init),
Some(margin_maint),
Some(maker_fee),
Some(taker_fee),
None, ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoFuture(instrument))
}
pub fn parse_trade(
trade: &BitmexTrade,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<TradeTick> {
let instrument_id = parse_instrument_id(trade.symbol);
let price = Price::new(trade.price, instrument.price_precision());
let size = parse_contracts_quantity(trade.size as u64, instrument);
let aggressor_side = parse_aggressor_side(&trade.side);
let trade_id = TradeId::new(
trade
.trd_match_id
.map_or_else(|| Uuid::new_v4().to_string(), |uuid| uuid.to_string()),
);
let ts_event = UnixNanos::from(trade.timestamp);
Ok(TradeTick::new(
instrument_id,
price,
size,
aggressor_side,
trade_id,
ts_event,
ts_init,
))
}
pub fn parse_trade_bin(
bin: &BitmexTradeBin,
instrument: &InstrumentAny,
bar_type: &BarType,
ts_init: UnixNanos,
) -> anyhow::Result<Bar> {
let instrument_id = bar_type.instrument_id();
let price_precision = instrument.price_precision();
let open = bin
.open
.ok_or_else(|| anyhow::anyhow!("Trade bin missing open price for {instrument_id}"))?;
let high = bin
.high
.ok_or_else(|| anyhow::anyhow!("Trade bin missing high price for {instrument_id}"))?;
let low = bin
.low
.ok_or_else(|| anyhow::anyhow!("Trade bin missing low price for {instrument_id}"))?;
let close = bin
.close
.ok_or_else(|| anyhow::anyhow!("Trade bin missing close price for {instrument_id}"))?;
let open = Price::new(open, price_precision);
let high = Price::new(high, price_precision);
let low = Price::new(low, price_precision);
let close = Price::new(close, price_precision);
let (open, high, low, close) =
normalize_trade_bin_prices(open, high, low, close, &bin.symbol, Some(bar_type));
let volume_contracts = normalize_trade_bin_volume(bin.volume, &bin.symbol);
let volume = parse_contracts_quantity(volume_contracts, instrument);
let ts_event = UnixNanos::from(bin.timestamp);
Ok(Bar::new(
*bar_type, open, high, low, close, volume, ts_event, ts_init,
))
}
pub fn parse_order_status_report(
order: &BitmexOrder,
instrument: &InstrumentAny,
order_type_cache: &DashMap<ClientOrderId, OrderType>,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let instrument_id = instrument.id();
let account_id = AccountId::new(format!("BITMEX-{}", order.account));
let venue_order_id = VenueOrderId::new(order.order_id.to_string());
let order_side: OrderSide = order
.side
.map_or(OrderSide::NoOrderSide, |side| side.into());
let order_type: OrderType = order.ord_type.map_or_else(
|| {
if let Some(cl_ord_id) = &order.cl_ord_id {
let client_order_id = ClientOrderId::new(cl_ord_id);
if let Some(cached_type) = order_type_cache.get(&client_order_id) {
log::debug!(
"Using cached ord_type={:?} for order {}",
*cached_type,
order.order_id,
);
return *cached_type;
}
}
let inferred = if order.stop_px.is_some() {
if order.price.is_some() {
OrderType::StopLimit
} else {
OrderType::StopMarket
}
} else if order.price.is_some() {
OrderType::Limit
} else {
OrderType::Market
};
log::debug!(
"Inferred ord_type={inferred:?} for order {} (price={:?}, stop_px={:?})",
order.order_id,
order.price,
order.stop_px,
);
inferred
},
|t| {
if t == BitmexOrderType::Pegged
&& order.peg_price_type == Some(BitmexPegPriceType::TrailingStopPeg)
{
if order.price.is_some() {
OrderType::TrailingStopLimit
} else {
OrderType::TrailingStopMarket
}
} else {
t.into()
}
},
);
let time_in_force: TimeInForce = order
.time_in_force
.and_then(|tif| tif.try_into().ok())
.unwrap_or(TimeInForce::Gtc);
let order_status: OrderStatus = if let Some(status) = order.ord_status.as_ref() {
(*status).into()
} else {
match (order.leaves_qty, order.cum_qty, order.working_indicator) {
(Some(0), Some(cum), _) if cum > 0 => {
log::debug!(
"Inferred Filled from missing ordStatus (leaves_qty=0, cum_qty>0): order_id={:?}, client_order_id={:?}, cum_qty={}",
order.order_id,
order.cl_ord_id,
cum,
);
OrderStatus::Filled
}
(Some(0), _, _) => {
log::debug!(
"Inferred Canceled from missing ordStatus (leaves_qty=0, cum_qty<=0): order_id={:?}, client_order_id={:?}, cum_qty={:?}",
order.order_id,
order.cl_ord_id,
order.cum_qty,
);
OrderStatus::Canceled
}
(None, None, Some(false)) => {
log::debug!(
"Inferred Canceled from missing ordStatus with working_indicator=false: order_id={:?}, client_order_id={:?}",
order.order_id,
order.cl_ord_id,
);
OrderStatus::Canceled
}
_ => {
let order_json = serde_json::to_string(order)?;
anyhow::bail!(
"Order missing ord_status and cannot infer (order_id={}, client_order_id={:?}, leaves_qty={:?}, cum_qty={:?}, working_indicator={:?}, order_json={})",
order.order_id,
order.cl_ord_id,
order.leaves_qty,
order.cum_qty,
order.working_indicator,
order_json
);
}
}
};
let (quantity, filled_qty) = if let Some(qty) = order.order_qty {
let quantity = parse_signed_contracts_quantity(qty, instrument);
let filled_qty = parse_signed_contracts_quantity(order.cum_qty.unwrap_or(0), instrument);
(quantity, filled_qty)
} else if let (Some(cum), Some(leaves)) = (order.cum_qty, order.leaves_qty) {
log::debug!(
"Reconstructing order_qty from cum_qty + leaves_qty: order_id={:?}, client_order_id={:?}, cum_qty={}, leaves_qty={}",
order.order_id,
order.cl_ord_id,
cum,
leaves,
);
let quantity = parse_signed_contracts_quantity(cum + leaves, instrument);
let filled_qty = parse_signed_contracts_quantity(cum, instrument);
(quantity, filled_qty)
} else if order_status == OrderStatus::Canceled || order_status == OrderStatus::Rejected {
log::debug!(
"Order missing quantity fields, using 0 for both (will be reconciled from cache): order_id={:?}, client_order_id={:?}, status={:?}",
order.order_id,
order.cl_ord_id,
order_status,
);
let zero_qty = Quantity::zero(instrument.size_precision());
(zero_qty, zero_qty)
} else {
anyhow::bail!(
"Order missing order_qty and cannot reconstruct (order_id={}, cum_qty={:?}, leaves_qty={:?})",
order.order_id,
order.cum_qty,
order.leaves_qty
);
};
let report_id = UUID4::new();
let ts_accepted = order.transact_time.map_or(ts_init, UnixNanos::from);
let ts_last = order.timestamp.map_or(ts_init, UnixNanos::from);
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
None, venue_order_id,
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_last,
ts_init,
Some(report_id),
);
if let Some(cl_ord_id) = order.cl_ord_id {
report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
}
if let Some(cl_ord_link_id) = order.cl_ord_link_id {
report = report.with_order_list_id(OrderListId::new(cl_ord_link_id));
}
let price_precision = instrument.price_precision();
if let Some(price) = order.price {
report = report.with_price(Price::new(price, price_precision));
}
if let Some(avg_px) = order.avg_px {
report = report.with_avg_px(avg_px)?;
}
if let Some(trigger_price) = order.stop_px {
report = report
.with_trigger_price(Price::new(trigger_price, price_precision))
.with_trigger_type(extract_trigger_type(order.exec_inst.as_ref()));
}
if matches!(
order_type,
OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
) && let Some(peg_offset) = order.peg_offset_value
{
let trailing_offset = Decimal::try_from(peg_offset.abs())
.unwrap_or_else(|_| Decimal::new(peg_offset.abs() as i64, 0));
report = report
.with_trailing_offset(trailing_offset)
.with_trailing_offset_type(TrailingOffsetType::Price);
if order.stop_px.is_none() {
report = report.with_trigger_type(extract_trigger_type(order.exec_inst.as_ref()));
}
}
if let Some(exec_instructions) = &order.exec_inst {
for inst in exec_instructions {
match inst {
BitmexExecInstruction::ParticipateDoNotInitiate => {
report = report.with_post_only(true);
}
BitmexExecInstruction::ReduceOnly => report = report.with_reduce_only(true),
BitmexExecInstruction::LastPrice
| BitmexExecInstruction::Close
| BitmexExecInstruction::MarkPrice
| BitmexExecInstruction::IndexPrice
| BitmexExecInstruction::AllOrNone
| BitmexExecInstruction::Fixed
| BitmexExecInstruction::Unknown => {}
}
}
}
if let Some(contingency_type) = order.contingency_type {
report = report.with_contingency_type(contingency_type.into());
}
if matches!(
report.contingency_type,
ContingencyType::Oco | ContingencyType::Oto | ContingencyType::Ouo
) && report.order_list_id.is_none()
{
log::debug!(
"BitMEX order missing clOrdLinkID for contingent order: order_id={}, client_order_id={:?}, contingency_type={:?}",
order.order_id,
report.client_order_id,
report.contingency_type,
);
}
if order_status == OrderStatus::Rejected {
if let Some(reason) = order.ord_rej_reason.or(order.text) {
log::debug!(
"Order rejected with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
order.order_id,
order.cl_ord_id,
reason,
);
report = report.with_cancel_reason(clean_reason(reason.as_ref()));
} else {
log::debug!(
"Order rejected without reason from BitMEX: order_id={:?}, client_order_id={:?}, ord_status={:?}, ord_rej_reason={:?}, text={:?}",
order.order_id,
order.cl_ord_id,
order.ord_status,
order.ord_rej_reason,
order.text,
);
}
} else if order_status == OrderStatus::Canceled
&& let Some(reason) = order.ord_rej_reason.or(order.text)
{
log::trace!(
"Order canceled with reason: order_id={:?}, client_order_id={:?}, reason={:?}",
order.order_id,
order.cl_ord_id,
reason,
);
report = report.with_cancel_reason(clean_reason(reason.as_ref()));
}
Ok(report)
}
pub fn parse_fill_report(
exec: &BitmexExecution,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<FillReport> {
if !matches!(exec.exec_type, BitmexExecType::Trade) {
anyhow::bail!("Skipping non-trade execution: {:?}", exec.exec_type);
}
let order_id = exec.order_id.ok_or_else(|| {
anyhow::anyhow!("Skipping execution without order_id: {:?}", exec.exec_type)
})?;
let account_id = AccountId::new(format!("BITMEX-{}", exec.account));
let instrument_id = instrument.id();
let venue_order_id = VenueOrderId::new(order_id.to_string());
let trade_id = TradeId::new(
exec.trd_match_id
.or(Some(exec.exec_id))
.ok_or_else(|| anyhow::anyhow!("Fill missing both trd_match_id and exec_id"))?
.to_string(),
);
let Some(side) = exec.side else {
anyhow::bail!("Skipping execution without side: {:?}", exec.exec_type);
};
let order_side: OrderSide = side.into();
let last_qty = parse_signed_contracts_quantity(exec.last_qty, instrument);
let last_px = Price::new(exec.last_px, instrument.price_precision());
let settlement_currency_str = exec.settl_currency.unwrap_or(Ustr::from("XBT")).as_str();
let mapped_currency = map_bitmex_currency(settlement_currency_str);
let currency = get_currency(&mapped_currency);
let commission = Money::new(exec.commission.unwrap_or(0.0), currency);
let liquidity_side = parse_liquidity_side(&exec.last_liquidity_ind);
let client_order_id = exec.cl_ord_id.map(ClientOrderId::new);
let venue_position_id = None; let ts_event = exec.transact_time.map_or(ts_init, UnixNanos::from);
Ok(FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission,
liquidity_side,
client_order_id,
venue_position_id,
ts_event,
ts_init,
None,
))
}
pub fn parse_position_report(
position: &BitmexPosition,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<PositionStatusReport> {
let account_id = AccountId::new(format!("BITMEX-{}", position.account));
let instrument_id = instrument.id();
let position_side = parse_position_side(position.current_qty).as_specified();
let quantity = parse_signed_contracts_quantity(position.current_qty.unwrap_or(0), instrument);
let venue_position_id = None; let avg_px_open = position
.avg_entry_price
.and_then(|p| Decimal::from_str(&p.to_string()).ok());
let ts_last = parse_optional_datetime_to_unix_nanos(&position.timestamp, "timestamp");
Ok(PositionStatusReport::new(
account_id,
instrument_id,
position_side,
quantity,
ts_last,
ts_init,
None, venue_position_id, avg_px_open, ))
}
pub fn get_currency(code: &str) -> Currency {
Currency::get_or_create_crypto(code)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use chrono::{DateTime, Utc};
use nautilus_model::{
data::{BarSpecification, BarType},
enums::{AggregationSource, BarAggregation, LiquiditySide, PositionSide, PriceType},
instruments::InstrumentAny,
};
use rstest::rstest;
use rust_decimal::{Decimal, prelude::ToPrimitive};
use uuid::Uuid;
use super::*;
use crate::{
common::{
enums::{
BitmexContingencyType, BitmexFairMethod, BitmexInstrumentState,
BitmexInstrumentType, BitmexLiquidityIndicator, BitmexMarkMethod,
BitmexOrderStatus, BitmexOrderType, BitmexSide, BitmexTickDirection,
BitmexTimeInForce,
},
testing::load_test_json,
},
http::models::{
BitmexExecution, BitmexInstrument, BitmexOrder, BitmexPosition, BitmexTradeBin,
BitmexWallet,
},
};
#[rstest]
fn test_perp_instrument_deserialization() {
let json_data = load_test_json("http_get_instrument_xbtusd.json");
let instrument: BitmexInstrument = serde_json::from_str(&json_data).unwrap();
assert_eq!(instrument.symbol, "XBTUSD");
assert_eq!(instrument.root_symbol, "XBT");
assert_eq!(instrument.state, BitmexInstrumentState::Open);
assert!(instrument.is_inverse);
assert_eq!(instrument.maker_fee, Some(0.0005));
assert_eq!(
instrument.timestamp.to_rfc3339(),
"2024-11-24T23:33:19.034+00:00"
);
}
#[rstest]
fn test_parse_orders() {
let json_data = load_test_json("http_get_orders.json");
let orders: Vec<BitmexOrder> = serde_json::from_str(&json_data).unwrap();
assert_eq!(orders.len(), 2);
let order1 = &orders[0];
assert_eq!(order1.symbol, Some(Ustr::from("XBTUSD")));
assert_eq!(order1.side, Some(BitmexSide::Buy));
assert_eq!(order1.order_qty, Some(100));
assert_eq!(order1.price, Some(98000.0));
assert_eq!(order1.ord_status, Some(BitmexOrderStatus::New));
assert_eq!(order1.leaves_qty, Some(100));
assert_eq!(order1.cum_qty, Some(0));
let order2 = &orders[1];
assert_eq!(order2.symbol, Some(Ustr::from("XBTUSD")));
assert_eq!(order2.side, Some(BitmexSide::Sell));
assert_eq!(order2.order_qty, Some(200));
assert_eq!(order2.ord_status, Some(BitmexOrderStatus::Filled));
assert_eq!(order2.leaves_qty, Some(0));
assert_eq!(order2.cum_qty, Some(200));
assert_eq!(order2.avg_px, Some(98950.5));
}
#[rstest]
fn test_parse_executions() {
let json_data = load_test_json("http_get_executions.json");
let executions: Vec<BitmexExecution> = serde_json::from_str(&json_data).unwrap();
assert_eq!(executions.len(), 2);
let exec1 = &executions[0];
assert_eq!(exec1.symbol, Some(Ustr::from("XBTUSD")));
assert_eq!(exec1.side, Some(BitmexSide::Sell));
assert_eq!(exec1.last_qty, 100);
assert_eq!(exec1.last_px, 98950.0);
assert_eq!(
exec1.last_liquidity_ind,
Some(BitmexLiquidityIndicator::Maker)
);
assert_eq!(exec1.commission, Some(0.00075));
let exec2 = &executions[1];
assert_eq!(
exec2.last_liquidity_ind,
Some(BitmexLiquidityIndicator::Taker)
);
assert_eq!(exec2.last_px, 98951.0);
}
#[rstest]
fn test_parse_positions() {
let json_data = load_test_json("http_get_positions.json");
let positions: Vec<BitmexPosition> = serde_json::from_str(&json_data).unwrap();
assert_eq!(positions.len(), 1);
let position = &positions[0];
assert_eq!(position.account, 1234567);
assert_eq!(position.symbol, "XBTUSD");
assert_eq!(position.current_qty, Some(100));
assert_eq!(position.avg_entry_price, Some(98390.88));
assert_eq!(position.unrealised_pnl, Some(1350));
assert_eq!(position.realised_pnl, Some(-227));
assert_eq!(position.is_open, Some(true));
}
#[rstest]
fn test_parse_trades() {
let json_data = load_test_json("http_get_trades.json");
let trades: Vec<BitmexTrade> = serde_json::from_str(&json_data).unwrap();
assert_eq!(trades.len(), 3);
let trade1 = &trades[0];
assert_eq!(trade1.symbol, "XBTUSD");
assert_eq!(trade1.side, Some(BitmexSide::Buy));
assert_eq!(trade1.size, 100);
assert_eq!(trade1.price, 98950.0);
let trade3 = &trades[2];
assert_eq!(trade3.side, Some(BitmexSide::Sell));
assert_eq!(trade3.size, 50);
assert_eq!(trade3.price, 98949.5);
}
#[rstest]
fn test_parse_wallet() {
let json_data = load_test_json("http_get_wallet.json");
let wallets: Vec<BitmexWallet> = serde_json::from_str(&json_data).unwrap();
assert_eq!(wallets.len(), 1);
let wallet = &wallets[0];
assert_eq!(wallet.account, 1234567);
assert_eq!(wallet.currency, "XBt");
assert_eq!(wallet.amount, Some(1000123456));
assert_eq!(wallet.delta_amount, Some(123456));
}
#[rstest]
fn test_parse_trade_bins() {
let json_data = load_test_json("http_get_trade_bins.json");
let bins: Vec<BitmexTradeBin> = serde_json::from_str(&json_data).unwrap();
assert_eq!(bins.len(), 3);
let bin1 = &bins[0];
assert_eq!(bin1.symbol, "XBTUSD");
assert_eq!(bin1.open, Some(98900.0));
assert_eq!(bin1.high, Some(98980.5));
assert_eq!(bin1.low, Some(98890.0));
assert_eq!(bin1.close, Some(98950.0));
assert_eq!(bin1.volume, Some(150000));
assert_eq!(bin1.trades, Some(45));
let bin3 = &bins[2];
assert_eq!(bin3.close, Some(98970.0));
assert_eq!(bin3.volume, Some(78000));
}
#[rstest]
fn test_parse_trade_bin_to_bar() {
let json_data = load_test_json("http_get_trade_bins.json");
let bins: Vec<BitmexTradeBin> = serde_json::from_str(&json_data).unwrap();
let instrument_json = load_test_json("http_get_instrument_xbtusd.json");
let instrument: BitmexInstrument = serde_json::from_str(&instrument_json).unwrap();
let ts_init = UnixNanos::from(1u64);
let instrument_any = match parse_instrument_any(&instrument, ts_init) {
InstrumentParseResult::Ok(inst) => inst,
other => panic!("Expected Ok, was {other:?}"),
};
let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
let bar_type = BarType::new(instrument_any.id(), spec, AggregationSource::External);
let bar = parse_trade_bin(&bins[0], &instrument_any, &bar_type, ts_init).unwrap();
let precision = instrument_any.price_precision();
let expected_open =
Price::from_decimal_dp(Decimal::from_str("98900.0").unwrap(), precision)
.expect("open price");
let expected_close =
Price::from_decimal_dp(Decimal::from_str("98950.0").unwrap(), precision)
.expect("close price");
assert_eq!(bar.bar_type, bar_type);
assert_eq!(bar.open, expected_open);
assert_eq!(bar.close, expected_close);
}
#[rstest]
fn test_parse_trade_bin_extreme_adjustment() {
let instrument_json = load_test_json("http_get_instrument_xbtusd.json");
let instrument: BitmexInstrument = serde_json::from_str(&instrument_json).unwrap();
let ts_init = UnixNanos::from(1u64);
let instrument_any = match parse_instrument_any(&instrument, ts_init) {
InstrumentParseResult::Ok(inst) => inst,
other => panic!("Expected Ok, was {other:?}"),
};
let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
let bar_type = BarType::new(instrument_any.id(), spec, AggregationSource::External);
let bin = BitmexTradeBin {
timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
symbol: Ustr::from("XBTUSD"),
open: Some(50_000.0),
high: Some(49_990.0),
low: Some(50_010.0),
close: Some(50_005.0),
trades: Some(5),
volume: Some(1_000),
vwap: None,
last_size: None,
turnover: None,
home_notional: None,
foreign_notional: None,
};
let bar = parse_trade_bin(&bin, &instrument_any, &bar_type, ts_init).unwrap();
let precision = instrument_any.price_precision();
let expected_high =
Price::from_decimal_dp(Decimal::from_str("50010.0").unwrap(), precision)
.expect("high price");
let expected_low = Price::from_decimal_dp(Decimal::from_str("49990.0").unwrap(), precision)
.expect("low price");
let expected_open =
Price::from_decimal_dp(Decimal::from_str("50000.0").unwrap(), precision)
.expect("open price");
assert_eq!(bar.high, expected_high);
assert_eq!(bar.low, expected_low);
assert_eq!(bar.open, expected_open);
}
#[rstest]
fn test_parse_order_status_report() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::New),
order_qty: Some(100),
cum_qty: Some(50),
price: Some(50000.0),
stop_px: Some(49000.0),
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: Some(vec![
BitmexExecInstruction::ParticipateDoNotInitiate,
BitmexExecInstruction::ReduceOnly,
]),
contingency_type: Some(BitmexContingencyType::OneCancelsTheOther),
ex_destination: None,
triggered: None,
working_indicator: Some(true),
ord_rej_reason: None,
leaves_qty: Some(50),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.account_id.to_string(), "BITMEX-123456");
assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
assert_eq!(
report.venue_order_id.as_str(),
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
);
assert_eq!(report.client_order_id.unwrap().as_str(), "client-123");
assert_eq!(report.quantity.as_f64(), 100.0);
assert_eq!(report.filled_qty.as_f64(), 50.0);
assert_eq!(report.price.unwrap().as_f64(), 50000.0);
assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
assert!(report.post_only);
assert!(report.reduce_only);
}
#[rstest]
fn test_parse_order_status_report_minimal() {
let order = BitmexOrder {
account: 0, symbol: Some(Ustr::from("ETHUSD")),
order_id: Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap(),
cl_ord_id: None,
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: Some(BitmexOrderType::Market),
time_in_force: Some(BitmexTimeInForce::ImmediateOrCancel),
ord_status: Some(BitmexOrderStatus::Filled),
order_qty: Some(200),
cum_qty: Some(200),
price: None,
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: None,
settl_currency: None,
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
leaves_qty: Some(0),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let mut instrument_def = create_test_perpetual_instrument();
instrument_def.symbol = Ustr::from("ETHUSD");
instrument_def.underlying = Ustr::from("ETH");
instrument_def.quote_currency = Ustr::from("USD");
instrument_def.settl_currency = Some(Ustr::from("USDt"));
let instrument = parse_perpetual_instrument(&instrument_def, UnixNanos::default()).unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.account_id.to_string(), "BITMEX-0");
assert_eq!(report.instrument_id.to_string(), "ETHUSD.BITMEX");
assert_eq!(
report.venue_order_id.as_str(),
"11111111-2222-3333-4444-555555555555"
);
assert!(report.client_order_id.is_none());
assert_eq!(report.quantity.as_f64(), 200.0);
assert_eq!(report.filled_qty.as_f64(), 200.0);
assert!(report.price.is_none());
assert!(report.trigger_price.is_none());
assert!(!report.post_only);
assert!(!report.reduce_only);
}
#[rstest]
fn test_parse_order_status_report_missing_order_qty_reconstructed() {
let order = BitmexOrder {
account: 789012,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("aaaabbbb-cccc-dddd-eeee-ffffffffffff").unwrap(),
cl_ord_id: Some(Ustr::from("client-cancel-test")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Canceled),
order_qty: None, cum_qty: Some(75), leaves_qty: Some(25), price: Some(45000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
avg_px: Some(45050.0),
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.quantity.as_f64(), 100.0); assert_eq!(report.filled_qty.as_f64(), 75.0);
assert_eq!(report.order_status, OrderStatus::Canceled);
}
#[rstest]
fn test_parse_order_status_report_uses_provided_order_qty() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("bbbbcccc-dddd-eeee-ffff-000000000000").unwrap(),
cl_ord_id: Some(Ustr::from("client-provided-qty")),
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::PartiallyFilled),
order_qty: Some(150), cum_qty: Some(50), leaves_qty: Some(100), price: Some(48000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(true),
ord_rej_reason: None,
avg_px: Some(48100.0),
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.quantity.as_f64(), 150.0);
assert_eq!(report.filled_qty.as_f64(), 50.0);
assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
}
#[rstest]
fn test_parse_order_status_report_missing_order_qty_fails() {
let order = BitmexOrder {
account: 789012,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("aaaabbbb-cccc-dddd-eeee-ffffffffffff").unwrap(),
cl_ord_id: Some(Ustr::from("client-fail-test")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::PartiallyFilled),
order_qty: None, cum_qty: Some(75), leaves_qty: None, price: Some(45000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let result =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Order missing order_qty and cannot reconstruct")
);
}
#[rstest]
fn test_parse_order_status_report_canceled_missing_all_quantities() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("ffff0000-1111-2222-3333-444444444444").unwrap(),
cl_ord_id: Some(Ustr::from("client-cancel-no-qty")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Canceled),
order_qty: None, cum_qty: None, leaves_qty: None, price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Canceled);
assert_eq!(report.quantity.as_f64(), 0.0);
assert_eq!(report.filled_qty.as_f64(), 0.0);
}
#[rstest]
fn test_parse_order_status_report_rejected_with_reason() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("ccccdddd-eeee-ffff-0000-111111111111").unwrap(),
cl_ord_id: Some(Ustr::from("client-rejected")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Rejected),
order_qty: Some(100),
cum_qty: Some(0),
leaves_qty: Some(0),
price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: Some(Ustr::from("Insufficient margin")),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Rejected);
assert_eq!(
report.cancel_reason,
Some("Insufficient margin".to_string())
);
}
#[rstest]
fn test_parse_order_status_report_rejected_with_text_fallback() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("ddddeeee-ffff-0000-1111-222222222222").unwrap(),
cl_ord_id: Some(Ustr::from("client-rejected-text")),
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Rejected),
order_qty: Some(100),
cum_qty: Some(0),
leaves_qty: Some(0),
price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
avg_px: None,
multi_leg_reporting_type: None,
text: Some(Ustr::from("Order would immediately execute")),
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Rejected);
assert_eq!(
report.cancel_reason,
Some("Order would immediately execute".to_string())
);
}
#[rstest]
fn test_parse_order_status_report_rejected_without_reason() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("eeeeffff-0000-1111-2222-333333333333").unwrap(),
cl_ord_id: Some(Ustr::from("client-rejected-no-reason")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Market),
time_in_force: Some(BitmexTimeInForce::ImmediateOrCancel),
ord_status: Some(BitmexOrderStatus::Rejected),
order_qty: Some(50),
cum_qty: Some(0),
leaves_qty: Some(0),
price: None,
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Rejected);
assert_eq!(report.cancel_reason, None);
}
#[rstest]
fn test_parse_fill_report() {
let exec = BitmexExecution {
exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
account: 654321,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
cl_ord_id: Some(Ustr::from("client-456")),
side: Some(BitmexSide::Buy),
last_qty: 50,
last_px: 50100.5,
commission: Some(0.00075),
settl_currency: Some(Ustr::from("XBt")),
last_liquidity_ind: Some(BitmexLiquidityIndicator::Taker),
trd_match_id: Some(Uuid::parse_str("99999999-8888-7777-6666-555555555555").unwrap()),
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
cl_ord_link_id: None,
underlying_last_px: None,
last_mkt: None,
order_qty: Some(50),
price: Some(50100.0),
display_qty: None,
stop_px: None,
peg_offset_value: None,
peg_price_type: None,
currency: None,
exec_type: BitmexExecType::Trade,
ord_type: BitmexOrderType::Limit,
time_in_force: BitmexTimeInForce::GoodTillCancel,
exec_inst: None,
contingency_type: None,
ex_destination: None,
ord_status: Some(BitmexOrderStatus::Filled),
triggered: None,
working_indicator: None,
ord_rej_reason: None,
leaves_qty: None,
cum_qty: Some(50),
avg_px: Some(50100.5),
trade_publish_indicator: None,
multi_leg_reporting_type: None,
text: None,
exec_cost: None,
exec_comm: None,
home_notional: None,
foreign_notional: None,
timestamp: None,
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report = parse_fill_report(&exec, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.account_id.to_string(), "BITMEX-654321");
assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
assert_eq!(
report.venue_order_id.as_str(),
"a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6"
);
assert_eq!(
report.trade_id.to_string(),
"99999999-8888-7777-6666-555555555555"
);
assert_eq!(report.client_order_id.unwrap().as_str(), "client-456");
assert_eq!(report.last_qty.as_f64(), 50.0);
assert_eq!(report.last_px.as_f64(), 50100.5);
assert_eq!(report.commission.as_f64(), 0.00075);
assert_eq!(report.commission.currency.code.as_str(), "XBT");
assert_eq!(report.liquidity_side, LiquiditySide::Taker);
}
#[rstest]
fn test_parse_fill_report_with_missing_trd_match_id() {
let exec = BitmexExecution {
exec_id: Uuid::parse_str("f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6").unwrap(),
account: 111111,
symbol: Some(Ustr::from("ETHUSD")),
order_id: Some(Uuid::parse_str("a1a2a3a4-b5b6-c7c8-d9d0-e1e2e3e4e5e6").unwrap()),
cl_ord_id: None,
side: Some(BitmexSide::Sell),
last_qty: 100,
last_px: 3000.0,
commission: None,
settl_currency: None,
last_liquidity_ind: Some(BitmexLiquidityIndicator::Maker),
trd_match_id: None, transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
cl_ord_link_id: None,
underlying_last_px: None,
last_mkt: None,
order_qty: Some(100),
price: Some(3000.0),
display_qty: None,
stop_px: None,
peg_offset_value: None,
peg_price_type: None,
currency: None,
exec_type: BitmexExecType::Trade,
ord_type: BitmexOrderType::Market,
time_in_force: BitmexTimeInForce::ImmediateOrCancel,
exec_inst: None,
contingency_type: None,
ex_destination: None,
ord_status: Some(BitmexOrderStatus::Filled),
triggered: None,
working_indicator: None,
ord_rej_reason: None,
leaves_qty: None,
cum_qty: Some(100),
avg_px: Some(3000.0),
trade_publish_indicator: None,
multi_leg_reporting_type: None,
text: None,
exec_cost: None,
exec_comm: None,
home_notional: None,
foreign_notional: None,
timestamp: None,
};
let mut instrument_def = create_test_perpetual_instrument();
instrument_def.symbol = Ustr::from("ETHUSD");
instrument_def.underlying = Ustr::from("ETH");
instrument_def.quote_currency = Ustr::from("USD");
instrument_def.settl_currency = Some(Ustr::from("USDt"));
let instrument = parse_perpetual_instrument(&instrument_def, UnixNanos::default()).unwrap();
let report = parse_fill_report(&exec, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.account_id.to_string(), "BITMEX-111111");
assert_eq!(report.instrument_id.to_string(), "ETHUSD.BITMEX");
assert_eq!(
report.trade_id.to_string(),
"f1f2f3f4-e5e6-d7d8-c9c0-b1b2b3b4b5b6"
);
assert!(report.client_order_id.is_none());
assert_eq!(report.commission.as_f64(), 0.0);
assert_eq!(report.commission.currency.code.as_str(), "XBT");
assert_eq!(report.liquidity_side, LiquiditySide::Maker);
}
#[rstest]
fn test_parse_position_report() {
let position = BitmexPosition {
account: 789012,
symbol: Ustr::from("XBTUSD"),
current_qty: Some(1000),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
currency: None,
underlying: None,
quote_currency: None,
commission: None,
init_margin_req: None,
maint_margin_req: None,
risk_limit: None,
leverage: None,
cross_margin: None,
deleverage_percentile: None,
rebalanced_pnl: None,
prev_realised_pnl: None,
prev_unrealised_pnl: None,
prev_close_price: None,
opening_timestamp: None,
opening_qty: None,
opening_cost: None,
opening_comm: None,
open_order_buy_qty: None,
open_order_buy_cost: None,
open_order_buy_premium: None,
open_order_sell_qty: None,
open_order_sell_cost: None,
open_order_sell_premium: None,
exec_buy_qty: None,
exec_buy_cost: None,
exec_sell_qty: None,
exec_sell_cost: None,
exec_qty: None,
exec_cost: None,
exec_comm: None,
current_timestamp: None,
current_cost: None,
current_comm: None,
realised_cost: None,
unrealised_cost: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
is_open: Some(true),
mark_price: None,
mark_value: None,
risk_value: None,
home_notional: None,
foreign_notional: None,
pos_state: None,
pos_cost: None,
pos_cost2: None,
pos_cross: None,
pos_init: None,
pos_comm: None,
pos_loss: None,
pos_margin: None,
pos_maint: None,
pos_allowance: None,
taxable_margin: None,
init_margin: None,
maint_margin: None,
session_margin: None,
target_excess_margin: None,
var_margin: None,
realised_gross_pnl: None,
realised_tax: None,
realised_pnl: None,
unrealised_gross_pnl: None,
long_bankrupt: None,
short_bankrupt: None,
tax_base: None,
indicative_tax_rate: None,
indicative_tax: None,
unrealised_tax: None,
unrealised_pnl: None,
unrealised_pnl_pcnt: None,
unrealised_roe_pcnt: None,
avg_cost_price: None,
avg_entry_price: None,
break_even_price: None,
margin_call_price: None,
liquidation_price: None,
bankrupt_price: None,
last_price: None,
last_value: None,
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report = parse_position_report(&position, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.account_id.to_string(), "BITMEX-789012");
assert_eq!(report.instrument_id.to_string(), "XBTUSD.BITMEX");
assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
assert_eq!(report.quantity.as_f64(), 1000.0);
}
#[rstest]
fn test_parse_position_report_short() {
let position = BitmexPosition {
account: 789012,
symbol: Ustr::from("ETHUSD"),
current_qty: Some(-500),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
currency: None,
underlying: None,
quote_currency: None,
commission: None,
init_margin_req: None,
maint_margin_req: None,
risk_limit: None,
leverage: None,
cross_margin: None,
deleverage_percentile: None,
rebalanced_pnl: None,
prev_realised_pnl: None,
prev_unrealised_pnl: None,
prev_close_price: None,
opening_timestamp: None,
opening_qty: None,
opening_cost: None,
opening_comm: None,
open_order_buy_qty: None,
open_order_buy_cost: None,
open_order_buy_premium: None,
open_order_sell_qty: None,
open_order_sell_cost: None,
open_order_sell_premium: None,
exec_buy_qty: None,
exec_buy_cost: None,
exec_sell_qty: None,
exec_sell_cost: None,
exec_qty: None,
exec_cost: None,
exec_comm: None,
current_timestamp: None,
current_cost: None,
current_comm: None,
realised_cost: None,
unrealised_cost: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
is_open: Some(true),
mark_price: None,
mark_value: None,
risk_value: None,
home_notional: None,
foreign_notional: None,
pos_state: None,
pos_cost: None,
pos_cost2: None,
pos_cross: None,
pos_init: None,
pos_comm: None,
pos_loss: None,
pos_margin: None,
pos_maint: None,
pos_allowance: None,
taxable_margin: None,
init_margin: None,
maint_margin: None,
session_margin: None,
target_excess_margin: None,
var_margin: None,
realised_gross_pnl: None,
realised_tax: None,
realised_pnl: None,
unrealised_gross_pnl: None,
long_bankrupt: None,
short_bankrupt: None,
tax_base: None,
indicative_tax_rate: None,
indicative_tax: None,
unrealised_tax: None,
unrealised_pnl: None,
unrealised_pnl_pcnt: None,
unrealised_roe_pcnt: None,
avg_cost_price: None,
avg_entry_price: None,
break_even_price: None,
margin_call_price: None,
liquidation_price: None,
bankrupt_price: None,
last_price: None,
last_value: None,
};
let mut instrument_def = create_test_futures_instrument();
instrument_def.symbol = Ustr::from("ETHUSD");
instrument_def.underlying = Ustr::from("ETH");
instrument_def.quote_currency = Ustr::from("USD");
instrument_def.settl_currency = Some(Ustr::from("USD"));
let instrument = parse_futures_instrument(&instrument_def, UnixNanos::default()).unwrap();
let report = parse_position_report(&position, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.position_side.as_position_side(), PositionSide::Short);
assert_eq!(report.quantity.as_f64(), 500.0); }
#[rstest]
fn test_parse_position_report_flat() {
let position = BitmexPosition {
account: 789012,
symbol: Ustr::from("SOLUSD"),
current_qty: Some(0),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
currency: None,
underlying: None,
quote_currency: None,
commission: None,
init_margin_req: None,
maint_margin_req: None,
risk_limit: None,
leverage: None,
cross_margin: None,
deleverage_percentile: None,
rebalanced_pnl: None,
prev_realised_pnl: None,
prev_unrealised_pnl: None,
prev_close_price: None,
opening_timestamp: None,
opening_qty: None,
opening_cost: None,
opening_comm: None,
open_order_buy_qty: None,
open_order_buy_cost: None,
open_order_buy_premium: None,
open_order_sell_qty: None,
open_order_sell_cost: None,
open_order_sell_premium: None,
exec_buy_qty: None,
exec_buy_cost: None,
exec_sell_qty: None,
exec_sell_cost: None,
exec_qty: None,
exec_cost: None,
exec_comm: None,
current_timestamp: None,
current_cost: None,
current_comm: None,
realised_cost: None,
unrealised_cost: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
is_open: Some(true),
mark_price: None,
mark_value: None,
risk_value: None,
home_notional: None,
foreign_notional: None,
pos_state: None,
pos_cost: None,
pos_cost2: None,
pos_cross: None,
pos_init: None,
pos_comm: None,
pos_loss: None,
pos_margin: None,
pos_maint: None,
pos_allowance: None,
taxable_margin: None,
init_margin: None,
maint_margin: None,
session_margin: None,
target_excess_margin: None,
var_margin: None,
realised_gross_pnl: None,
realised_tax: None,
realised_pnl: None,
unrealised_gross_pnl: None,
long_bankrupt: None,
short_bankrupt: None,
tax_base: None,
indicative_tax_rate: None,
indicative_tax: None,
unrealised_tax: None,
unrealised_pnl: None,
unrealised_pnl_pcnt: None,
unrealised_roe_pcnt: None,
avg_cost_price: None,
avg_entry_price: None,
break_even_price: None,
margin_call_price: None,
liquidation_price: None,
bankrupt_price: None,
last_price: None,
last_value: None,
};
let mut instrument_def = create_test_spot_instrument();
instrument_def.symbol = Ustr::from("SOLUSD");
instrument_def.underlying = Ustr::from("SOL");
instrument_def.quote_currency = Ustr::from("USD");
let instrument = parse_spot_instrument(&instrument_def, UnixNanos::default()).unwrap();
let report = parse_position_report(&position, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.position_side.as_position_side(), PositionSide::Flat);
assert_eq!(report.quantity.as_f64(), 0.0);
}
#[rstest]
fn test_parse_position_report_spot_scaling() {
let position = BitmexPosition {
account: 789012,
symbol: Ustr::from("SOLUSD"),
current_qty: Some(1000),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
currency: None,
underlying: None,
quote_currency: None,
commission: None,
init_margin_req: None,
maint_margin_req: None,
risk_limit: None,
leverage: None,
cross_margin: None,
deleverage_percentile: None,
rebalanced_pnl: None,
prev_realised_pnl: None,
prev_unrealised_pnl: None,
prev_close_price: None,
opening_timestamp: None,
opening_qty: None,
opening_cost: None,
opening_comm: None,
open_order_buy_qty: None,
open_order_buy_cost: None,
open_order_buy_premium: None,
open_order_sell_qty: None,
open_order_sell_cost: None,
open_order_sell_premium: None,
exec_buy_qty: None,
exec_buy_cost: None,
exec_sell_qty: None,
exec_sell_cost: None,
exec_qty: None,
exec_cost: None,
exec_comm: None,
current_timestamp: None,
current_cost: None,
current_comm: None,
realised_cost: None,
unrealised_cost: None,
gross_open_cost: None,
gross_open_premium: None,
gross_exec_cost: None,
is_open: Some(true),
mark_price: None,
mark_value: None,
risk_value: None,
home_notional: None,
foreign_notional: None,
pos_state: None,
pos_cost: None,
pos_cost2: None,
pos_cross: None,
pos_init: None,
pos_comm: None,
pos_loss: None,
pos_margin: None,
pos_maint: None,
pos_allowance: None,
taxable_margin: None,
init_margin: None,
maint_margin: None,
session_margin: None,
target_excess_margin: None,
var_margin: None,
realised_gross_pnl: None,
realised_tax: None,
realised_pnl: None,
unrealised_gross_pnl: None,
long_bankrupt: None,
short_bankrupt: None,
tax_base: None,
indicative_tax_rate: None,
indicative_tax: None,
unrealised_tax: None,
unrealised_pnl: None,
unrealised_pnl_pcnt: None,
unrealised_roe_pcnt: None,
avg_cost_price: None,
avg_entry_price: None,
break_even_price: None,
margin_call_price: None,
liquidation_price: None,
bankrupt_price: None,
last_price: None,
last_value: None,
};
let mut instrument_def = create_test_spot_instrument();
instrument_def.symbol = Ustr::from("SOLUSD");
instrument_def.underlying = Ustr::from("SOL");
instrument_def.quote_currency = Ustr::from("USD");
let instrument = parse_spot_instrument(&instrument_def, UnixNanos::default()).unwrap();
let report = parse_position_report(&position, &instrument, UnixNanos::from(1)).unwrap();
assert_eq!(report.position_side.as_position_side(), PositionSide::Long);
assert!((report.quantity.as_f64() - 0.1).abs() < 1e-9);
}
fn create_test_spot_instrument() -> BitmexInstrument {
BitmexInstrument {
symbol: Ustr::from("XBTUSD"),
root_symbol: Ustr::from("XBT"),
state: BitmexInstrumentState::Open,
instrument_type: BitmexInstrumentType::Spot,
listing: Some(
DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
front: Some(
DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
expiry: None,
settle: None,
listed_settle: None,
position_currency: Some(Ustr::from("USD")),
underlying: Ustr::from("XBT"),
quote_currency: Ustr::from("USD"),
underlying_symbol: Some(Ustr::from("XBT=")),
reference: Some(Ustr::from("BMEX")),
reference_symbol: Some(Ustr::from(".BXBT")),
lot_size: Some(1000.0),
tick_size: 0.01,
multiplier: 1.0,
settl_currency: Some(Ustr::from("USD")),
is_quanto: false,
is_inverse: false,
maker_fee: Some(-0.00025),
taker_fee: Some(0.00075),
timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
max_order_qty: Some(10000000.0),
max_price: Some(1000000.0),
min_price: None,
settlement_fee: Some(0.0),
mark_price: Some(50500.0),
last_price: Some(50500.0),
bid_price: Some(50499.5),
ask_price: Some(50500.5),
open_interest: Some(0.0),
open_value: Some(0.0),
total_volume: Some(1000000.0),
volume: Some(50000.0),
volume_24h: Some(75000.0),
total_turnover: Some(150000000.0),
turnover: Some(5000000.0),
turnover_24h: Some(7500000.0),
has_liquidity: Some(true),
calc_interval: None,
publish_interval: None,
publish_time: None,
underlying_to_position_multiplier: Some(10000.0),
underlying_to_settle_multiplier: None,
quote_to_settle_multiplier: Some(1.0),
init_margin: Some(0.1),
maint_margin: Some(0.05),
risk_limit: Some(20000000000.0),
risk_step: Some(10000000000.0),
limit: None,
taxed: Some(true),
deleverage: Some(true),
funding_base_symbol: None,
funding_quote_symbol: None,
funding_premium_symbol: None,
funding_timestamp: None,
funding_interval: None,
funding_rate: None,
indicative_funding_rate: None,
rebalance_timestamp: None,
rebalance_interval: None,
prev_close_price: Some(50000.0),
limit_down_price: None,
limit_up_price: None,
prev_total_turnover: Some(100000000.0),
home_notional_24h: Some(1.5),
foreign_notional_24h: Some(75000.0),
prev_price_24h: Some(49500.0),
vwap: Some(50100.0),
high_price: Some(51000.0),
low_price: Some(49000.0),
last_price_protected: Some(50500.0),
last_tick_direction: Some(BitmexTickDirection::PlusTick),
last_change_pcnt: Some(0.0202),
mid_price: Some(50500.0),
impact_bid_price: Some(50490.0),
impact_mid_price: Some(50495.0),
impact_ask_price: Some(50500.0),
fair_method: None,
fair_basis_rate: None,
fair_basis: None,
fair_price: None,
mark_method: Some(BitmexMarkMethod::LastPrice),
indicative_settle_price: None,
settled_price_adjustment_rate: None,
settled_price: None,
instant_pnl: false,
min_tick: None,
funding_base_rate: None,
funding_quote_rate: None,
capped: None,
opening_timestamp: None,
closing_timestamp: None,
prev_total_volume: None,
}
}
fn create_test_perpetual_instrument() -> BitmexInstrument {
BitmexInstrument {
symbol: Ustr::from("XBTUSD"),
root_symbol: Ustr::from("XBT"),
state: BitmexInstrumentState::Open,
instrument_type: BitmexInstrumentType::PerpetualContract,
listing: Some(
DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
front: Some(
DateTime::parse_from_rfc3339("2016-05-13T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
expiry: None,
settle: None,
listed_settle: None,
position_currency: Some(Ustr::from("USD")),
underlying: Ustr::from("XBT"),
quote_currency: Ustr::from("USD"),
underlying_symbol: Some(Ustr::from("XBT=")),
reference: Some(Ustr::from("BMEX")),
reference_symbol: Some(Ustr::from(".BXBT")),
lot_size: Some(100.0),
tick_size: 0.5,
multiplier: -100000000.0,
settl_currency: Some(Ustr::from("XBt")),
is_quanto: false,
is_inverse: true,
maker_fee: Some(-0.00025),
taker_fee: Some(0.00075),
timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
max_order_qty: Some(10000000.0),
max_price: Some(1000000.0),
min_price: None,
settlement_fee: Some(0.0),
mark_price: Some(50500.01),
last_price: Some(50500.0),
bid_price: Some(50499.5),
ask_price: Some(50500.5),
open_interest: Some(500000000.0),
open_value: Some(990099009900.0),
total_volume: Some(12345678900000.0),
volume: Some(5000000.0),
volume_24h: Some(75000000.0),
total_turnover: Some(150000000000000.0),
turnover: Some(5000000000.0),
turnover_24h: Some(7500000000.0),
has_liquidity: Some(true),
funding_base_symbol: Some(Ustr::from(".XBTBON8H")),
funding_quote_symbol: Some(Ustr::from(".USDBON8H")),
funding_premium_symbol: Some(Ustr::from(".XBTUSDPI8H")),
funding_timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T08:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
funding_interval: Some(
DateTime::parse_from_rfc3339("2000-01-01T08:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
funding_rate: Some(Decimal::from_str("0.0001").unwrap()),
indicative_funding_rate: Some(Decimal::from_str("0.0001").unwrap()),
funding_base_rate: Some(0.01),
funding_quote_rate: Some(-0.01),
calc_interval: None,
publish_interval: None,
publish_time: None,
underlying_to_position_multiplier: None,
underlying_to_settle_multiplier: Some(-100000000.0),
quote_to_settle_multiplier: None,
init_margin: Some(0.01),
maint_margin: Some(0.005),
risk_limit: Some(20000000000.0),
risk_step: Some(10000000000.0),
limit: None,
taxed: Some(true),
deleverage: Some(true),
rebalance_timestamp: None,
rebalance_interval: None,
prev_close_price: Some(50000.0),
limit_down_price: None,
limit_up_price: None,
prev_total_turnover: Some(100000000000000.0),
home_notional_24h: Some(1500.0),
foreign_notional_24h: Some(75000000.0),
prev_price_24h: Some(49500.0),
vwap: Some(50100.0),
high_price: Some(51000.0),
low_price: Some(49000.0),
last_price_protected: Some(50500.0),
last_tick_direction: Some(BitmexTickDirection::PlusTick),
last_change_pcnt: Some(0.0202),
mid_price: Some(50500.0),
impact_bid_price: Some(50490.0),
impact_mid_price: Some(50495.0),
impact_ask_price: Some(50500.0),
fair_method: Some(BitmexFairMethod::FundingRate),
fair_basis_rate: Some(0.1095),
fair_basis: Some(0.01),
fair_price: Some(50500.01),
mark_method: Some(BitmexMarkMethod::FairPrice),
indicative_settle_price: Some(50500.0),
settled_price_adjustment_rate: None,
settled_price: None,
instant_pnl: false,
min_tick: None,
capped: None,
opening_timestamp: None,
closing_timestamp: None,
prev_total_volume: None,
}
}
fn create_test_futures_instrument() -> BitmexInstrument {
BitmexInstrument {
symbol: Ustr::from("XBTH25"),
root_symbol: Ustr::from("XBT"),
state: BitmexInstrumentState::Open,
instrument_type: BitmexInstrumentType::Futures,
listing: Some(
DateTime::parse_from_rfc3339("2024-09-27T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
front: Some(
DateTime::parse_from_rfc3339("2024-12-27T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
expiry: Some(
DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
settle: Some(
DateTime::parse_from_rfc3339("2025-03-28T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
),
listed_settle: None,
position_currency: Some(Ustr::from("USD")),
underlying: Ustr::from("XBT"),
quote_currency: Ustr::from("USD"),
underlying_symbol: Some(Ustr::from("XBT=")),
reference: Some(Ustr::from("BMEX")),
reference_symbol: Some(Ustr::from(".BXBT30M")),
lot_size: Some(100.0),
tick_size: 0.5,
multiplier: -100000000.0,
settl_currency: Some(Ustr::from("XBt")),
is_quanto: false,
is_inverse: true,
maker_fee: Some(-0.00025),
taker_fee: Some(0.00075),
settlement_fee: Some(0.0005),
timestamp: DateTime::parse_from_rfc3339("2024-01-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
max_order_qty: Some(10000000.0),
max_price: Some(1000000.0),
min_price: None,
mark_price: Some(55500.0),
last_price: Some(55500.0),
bid_price: Some(55499.5),
ask_price: Some(55500.5),
open_interest: Some(50000000.0),
open_value: Some(90090090090.0),
total_volume: Some(1000000000.0),
volume: Some(500000.0),
volume_24h: Some(7500000.0),
total_turnover: Some(15000000000000.0),
turnover: Some(500000000.0),
turnover_24h: Some(750000000.0),
has_liquidity: Some(true),
funding_base_symbol: None,
funding_quote_symbol: None,
funding_premium_symbol: None,
funding_timestamp: None,
funding_interval: None,
funding_rate: None,
indicative_funding_rate: None,
funding_base_rate: None,
funding_quote_rate: None,
calc_interval: None,
publish_interval: None,
publish_time: None,
underlying_to_position_multiplier: None,
underlying_to_settle_multiplier: Some(-100000000.0),
quote_to_settle_multiplier: None,
init_margin: Some(0.02),
maint_margin: Some(0.01),
risk_limit: Some(20000000000.0),
risk_step: Some(10000000000.0),
limit: None,
taxed: Some(true),
deleverage: Some(true),
rebalance_timestamp: None,
rebalance_interval: None,
prev_close_price: Some(55000.0),
limit_down_price: None,
limit_up_price: None,
prev_total_turnover: Some(10000000000000.0),
home_notional_24h: Some(150.0),
foreign_notional_24h: Some(7500000.0),
prev_price_24h: Some(54500.0),
vwap: Some(55100.0),
high_price: Some(56000.0),
low_price: Some(54000.0),
last_price_protected: Some(55500.0),
last_tick_direction: Some(BitmexTickDirection::PlusTick),
last_change_pcnt: Some(0.0183),
mid_price: Some(55500.0),
impact_bid_price: Some(55490.0),
impact_mid_price: Some(55495.0),
impact_ask_price: Some(55500.0),
fair_method: Some(BitmexFairMethod::ImpactMidPrice),
fair_basis_rate: Some(1.8264),
fair_basis: Some(1000.0),
fair_price: Some(55500.0),
mark_method: Some(BitmexMarkMethod::FairPrice),
indicative_settle_price: Some(55500.0),
settled_price_adjustment_rate: None,
settled_price: None,
instant_pnl: false,
min_tick: None,
capped: None,
opening_timestamp: None,
closing_timestamp: None,
prev_total_volume: None,
}
}
#[rstest]
fn test_parse_spot_instrument() {
let instrument = create_test_spot_instrument();
let ts_init = UnixNanos::default();
let result = parse_spot_instrument(&instrument, ts_init).unwrap();
match result {
InstrumentAny::CurrencyPair(spot) => {
assert_eq!(spot.id.symbol.as_str(), "XBTUSD");
assert_eq!(spot.id.venue.as_str(), "BITMEX");
assert_eq!(spot.raw_symbol.as_str(), "XBTUSD");
assert_eq!(spot.price_precision, 2);
assert_eq!(spot.size_precision, 4);
assert_eq!(spot.price_increment.as_f64(), 0.01);
assert!((spot.size_increment.as_f64() - 0.0001).abs() < 1e-9);
assert!((spot.lot_size.unwrap().as_f64() - 0.1).abs() < 1e-9);
assert_eq!(spot.maker_fee.to_f64().unwrap(), -0.00025);
assert_eq!(spot.taker_fee.to_f64().unwrap(), 0.00075);
}
_ => panic!("Expected CurrencyPair variant"),
}
}
#[rstest]
fn test_parse_perpetual_instrument() {
let instrument = create_test_perpetual_instrument();
let ts_init = UnixNanos::default();
let result = parse_perpetual_instrument(&instrument, ts_init).unwrap();
match result {
InstrumentAny::CryptoPerpetual(perp) => {
assert_eq!(perp.id.symbol.as_str(), "XBTUSD");
assert_eq!(perp.id.venue.as_str(), "BITMEX");
assert_eq!(perp.raw_symbol.as_str(), "XBTUSD");
assert_eq!(perp.price_precision, 1);
assert_eq!(perp.size_precision, 0);
assert_eq!(perp.price_increment.as_f64(), 0.5);
assert_eq!(perp.size_increment.as_f64(), 1.0);
assert_eq!(perp.maker_fee.to_f64().unwrap(), -0.00025);
assert_eq!(perp.taker_fee.to_f64().unwrap(), 0.00075);
assert!(perp.is_inverse);
}
_ => panic!("Expected CryptoPerpetual variant"),
}
}
#[rstest]
fn test_parse_futures_instrument() {
let instrument = create_test_futures_instrument();
let ts_init = UnixNanos::default();
let result = parse_futures_instrument(&instrument, ts_init).unwrap();
match result {
InstrumentAny::CryptoFuture(instrument) => {
assert_eq!(instrument.id.symbol.as_str(), "XBTH25");
assert_eq!(instrument.id.venue.as_str(), "BITMEX");
assert_eq!(instrument.raw_symbol.as_str(), "XBTH25");
assert_eq!(instrument.underlying.code.as_str(), "XBT");
assert_eq!(instrument.price_precision, 1);
assert_eq!(instrument.size_precision, 0);
assert_eq!(instrument.price_increment.as_f64(), 0.5);
assert_eq!(instrument.size_increment.as_f64(), 1.0);
assert_eq!(instrument.maker_fee.to_f64().unwrap(), -0.00025);
assert_eq!(instrument.taker_fee.to_f64().unwrap(), 0.00075);
assert!(instrument.is_inverse);
assert!(instrument.expiration_ns.as_u64() > 0);
}
_ => panic!("Expected CryptoFuture variant"),
}
}
#[rstest]
fn test_parse_order_status_report_missing_ord_status_infers_filled() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-filled")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: None, order_qty: Some(100),
cum_qty: Some(100), price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
leaves_qty: Some(0), avg_px: Some(50050.0),
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Filled);
assert_eq!(report.account_id.to_string(), "BITMEX-123456");
assert_eq!(report.filled_qty.as_f64(), 100.0);
}
#[rstest]
fn test_parse_order_status_report_missing_ord_status_infers_canceled() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("b2c3d4e5-f6a7-8901-bcde-f12345678901").unwrap(),
cl_ord_id: Some(Ustr::from("client-canceled")),
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: None, order_qty: Some(200),
cum_qty: Some(0), price: Some(60000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
leaves_qty: Some(0), avg_px: None,
multi_leg_reporting_type: None,
text: Some(Ustr::from("Canceled: Already filled")),
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_status, OrderStatus::Canceled);
assert_eq!(report.account_id.to_string(), "BITMEX-123456");
assert_eq!(report.filled_qty.as_f64(), 0.0);
assert_eq!(
report.cancel_reason.as_ref().unwrap(),
"Canceled: Already filled"
);
}
#[rstest]
fn test_parse_order_status_report_missing_ord_status_with_leaves_qty_fails() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("c3d4e5f6-a7b8-9012-cdef-123456789012").unwrap(),
cl_ord_id: Some(Ustr::from("client-partial")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: None, order_qty: Some(100),
cum_qty: Some(50),
price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(true),
ord_rej_reason: None,
leaves_qty: Some(50), avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let result =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("missing ord_status"));
assert!(err_msg.contains("cannot infer"));
}
#[rstest]
fn test_parse_order_status_report_missing_ord_status_no_quantities_fails() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("d4e5f6a7-b8c9-0123-def0-123456789013").unwrap(),
cl_ord_id: Some(Ustr::from("client-unknown")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: Some(BitmexOrderType::Limit),
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: None, order_qty: Some(100),
cum_qty: None, price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(true),
ord_rej_reason: None,
leaves_qty: None, avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let result =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("missing ord_status"));
assert!(err_msg.contains("cannot infer"));
}
#[rstest]
fn test_parse_order_status_report_infers_market_order_type() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: None,
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Filled),
order_qty: Some(100),
cum_qty: Some(100),
price: None,
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: None,
ord_rej_reason: None,
leaves_qty: Some(0),
avg_px: Some(50000.0),
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_type, OrderType::Market);
}
#[rstest]
fn test_parse_order_status_report_infers_limit_order_type() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: None,
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::New),
order_qty: Some(100),
cum_qty: Some(0),
price: Some(50000.0),
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(true),
ord_rej_reason: None,
leaves_qty: Some(100),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_type, OrderType::Limit);
}
#[rstest]
fn test_parse_order_status_report_infers_stop_market_order_type() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: None,
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::New),
order_qty: Some(100),
cum_qty: Some(0),
price: None,
stop_px: Some(45000.0),
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
leaves_qty: Some(100),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_type, OrderType::StopMarket);
}
#[rstest]
fn test_parse_order_status_report_infers_stop_limit_order_type() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Sell),
ord_type: None,
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::New),
order_qty: Some(100),
cum_qty: Some(0),
price: Some(44000.0),
stop_px: Some(45000.0),
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: Some(false),
ord_rej_reason: None,
leaves_qty: Some(100),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let report =
parse_order_status_report(&order, &instrument, &DashMap::default(), UnixNanos::from(1))
.unwrap();
assert_eq!(report.order_type, OrderType::StopLimit);
}
#[rstest]
fn test_parse_order_status_report_uses_cached_order_type() {
let order = BitmexOrder {
account: 123456,
symbol: Some(Ustr::from("XBTUSD")),
order_id: Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(),
cl_ord_id: Some(Ustr::from("client-123")),
cl_ord_link_id: None,
side: Some(BitmexSide::Buy),
ord_type: None,
time_in_force: Some(BitmexTimeInForce::GoodTillCancel),
ord_status: Some(BitmexOrderStatus::Canceled),
order_qty: None,
cum_qty: Some(0),
price: None,
stop_px: None,
display_qty: None,
peg_offset_value: None,
peg_price_type: None,
currency: Some(Ustr::from("USD")),
settl_currency: Some(Ustr::from("XBt")),
exec_inst: None,
contingency_type: None,
ex_destination: None,
triggered: None,
working_indicator: None,
ord_rej_reason: None,
leaves_qty: Some(0),
avg_px: None,
multi_leg_reporting_type: None,
text: None,
transact_time: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
timestamp: Some(
DateTime::parse_from_rfc3339("2024-01-01T00:00:01Z")
.unwrap()
.with_timezone(&Utc),
),
};
let instrument =
parse_perpetual_instrument(&create_test_perpetual_instrument(), UnixNanos::default())
.unwrap();
let cache: DashMap<ClientOrderId, OrderType> = DashMap::new();
cache.insert(ClientOrderId::new("client-123"), OrderType::StopLimit);
let report =
parse_order_status_report(&order, &instrument, &cache, UnixNanos::from(1)).unwrap();
assert_eq!(report.order_type, OrderType::StopLimit);
}
}