use std::str::FromStr;
use anyhow::Context;
pub use nautilus_core::serialization::{
deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
deserialize_optional_string_to_u64, deserialize_string_to_u64,
};
use nautilus_core::{Params, UUID4, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
use nautilus_model::{
data::{
Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
TradeTick,
bar::{
BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
BAR_SPEC_30_MINUTE_LAST,
},
},
enums::{
AccountType, AggregationSource, AggressorSide, AssetClass, LiquiditySide,
MarketStatusAction, OptionKind, OrderSide, OrderStatus, OrderType, PositionSide,
TimeInForce,
},
events::AccountState,
identifiers::{
AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, VenueOrderId,
},
instruments::{
BinaryOption, CryptoFuture, CryptoFuturesSpread, CryptoOption, CryptoOptionSpread,
CryptoPerpetual, CurrencyPair, InstrumentAny,
},
reports::{FillReport, OrderStatusReport, PositionStatusReport},
types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, de::DeserializeOwned};
use ustr::Ustr;
use super::enums::OKXContractType;
use crate::{
common::{
consts::OKX_VENUE,
enums::{
OKXExecType, OKXInstrumentCategory, OKXInstrumentStatus, OKXInstrumentType,
OKXOrderCategory, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
OKXSpreadState, OKXSpreadType, OKXTargetCurrency, OKXVipLevel,
},
models::OKXInstrument,
},
http::models::{
OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXFundingRateHistory, OKXIndexTicker,
OKXMarkPrice, OKXOrderHistory, OKXPosition, OKXSpread, OKXSpreadOrder, OKXSpreadTrade,
OKXTrade, OKXTransactionDetail,
},
websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
};
pub(crate) fn prefer_rpi_response_fields(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(fields) => {
for (current, legacy) in [("rpi", "elp"), ("rpiMaker", "elpMaker")] {
if fields.contains_key(current) {
fields.remove(legacy);
} else if let Some(legacy_value) = fields.remove(legacy) {
fields.insert(current.to_string(), legacy_value);
}
}
for nested in fields.values_mut() {
prefer_rpi_response_fields(nested);
}
}
serde_json::Value::Array(items) => {
for item in items {
prefer_rpi_response_fields(item);
}
}
_ => {}
}
}
pub fn is_market_price(px: &str) -> bool {
px.is_empty() || px == "0" || px == "-1" || px == "-2"
}
pub fn determine_order_type(okx_ord_type: OKXOrderType, px: &str) -> OrderType {
determine_order_type_with_alt(okx_ord_type, px, "", "")
}
pub fn determine_order_type_with_alt(
okx_ord_type: OKXOrderType,
px: &str,
px_vol: &str,
px_usd: &str,
) -> OrderType {
match okx_ord_type {
OKXOrderType::OpFok => OrderType::Limit,
OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
let has_alt_price = !px_vol.is_empty() || !px_usd.is_empty();
if has_alt_price || !is_market_price(px) {
OrderType::Limit
} else {
OrderType::Market
}
}
_ => okx_ord_type.into(),
}
}
pub fn deserialize_target_currency_as_none<'de, D>(
deserializer: D,
) -> Result<Option<OKXTargetCurrency>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
s.parse().map(Some).map_err(serde::de::Error::custom)
}
}
pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
return Ok(OKXVipLevel::Vip0);
}
let level_str = if s.len() >= 3 && s[..3].eq_ignore_ascii_case("vip") {
&s[3..]
} else if s.len() >= 2 && s[..2].eq_ignore_ascii_case("lv") {
&s[2..]
} else {
&s
};
let level_num = level_str
.parse::<u8>()
.map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
Ok(OKXVipLevel::from(level_num))
}
pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
match instrument {
InstrumentAny::BinaryOption(_) => Ok(OKXInstrumentType::Events),
InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
_ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
}
}
#[must_use]
pub fn is_okx_spread_symbol(symbol: &str) -> bool {
symbol.contains('_')
}
pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
let dash_count = symbol.bytes().filter(|&b| b == b'-').count();
match dash_count {
1 => OKXInstrumentType::Spot, 2 => {
let suffix = symbol.rsplit('-').next().unwrap_or("");
if suffix == "SWAP" {
OKXInstrumentType::Swap
} else if suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_digit()) {
OKXInstrumentType::Futures
} else {
OKXInstrumentType::Spot
}
}
4 => {
let suffix = symbol.rsplit('-').next().unwrap_or("");
if matches!(suffix, "C" | "P") {
OKXInstrumentType::Option
} else {
OKXInstrumentType::Events
}
}
_ if dash_count > 4 => OKXInstrumentType::Events,
_ => OKXInstrumentType::Spot, }
}
pub fn parse_base_quote_from_symbol(symbol: &str) -> anyhow::Result<(&str, &str)> {
let mut parts = symbol.split('-');
let base = parts.next().ok_or_else(|| {
anyhow::anyhow!("Invalid symbol format: missing base currency in '{symbol}'")
})?;
let quote = parts.next().ok_or_else(|| {
anyhow::anyhow!("Invalid symbol format: missing quote currency in '{symbol}'")
})?;
Ok((base, quote))
}
pub fn extract_inst_family(symbol: &str) -> anyhow::Result<Ustr> {
let (base, quote) = parse_base_quote_from_symbol(symbol)?;
Ok(Ustr::from(&format!("{base}-{quote}")))
}
#[must_use]
pub fn okx_status_to_market_action(status: OKXInstrumentStatus) -> MarketStatusAction {
match status {
OKXInstrumentStatus::Live => MarketStatusAction::Trading,
OKXInstrumentStatus::Suspend => MarketStatusAction::Suspend,
OKXInstrumentStatus::Preopen => MarketStatusAction::PreOpen,
OKXInstrumentStatus::Test => MarketStatusAction::NotAvailableForTrading,
OKXInstrumentStatus::PostOnly => MarketStatusAction::Quoting,
OKXInstrumentStatus::Rebase => MarketStatusAction::NotAvailableForTrading,
OKXInstrumentStatus::Settling => MarketStatusAction::NotAvailableForTrading,
OKXInstrumentStatus::Unknown => MarketStatusAction::NotAvailableForTrading,
}
}
#[must_use]
pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
}
#[must_use]
pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
if value.is_empty() {
None
} else {
Some(ClientOrderId::new(value))
}
}
#[must_use]
pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
}
pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
})?;
if nanos < 0 {
anyhow::bail!("Negative nanosecond timestamp from: {timestamp}");
}
Ok(UnixNanos::from(nanos as u64))
}
pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
let decimal = Decimal::from_str(value)?;
Price::from_decimal_dp(decimal, precision).map_err(Into::into)
}
pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
let decimal = Decimal::from_str(value)?;
Quantity::from_decimal_dp(decimal, precision).map_err(Into::into)
}
pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
let decimal = Decimal::from_str(value.unwrap_or("0"))?;
Money::from_decimal(-decimal, currency).map_err(Into::into)
}
pub fn parse_fee_currency(
fee_ccy: &str,
fee_amount: Decimal,
context: impl FnOnce() -> String,
) -> Currency {
let trimmed = fee_ccy.trim();
if trimmed.is_empty() {
if !fee_amount.is_zero() {
let ctx = context();
log::warn!(
"Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
);
}
return Currency::USDT();
}
Currency::get_or_create_crypto(trimmed)
}
pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
match side {
Some(OKXSide::Buy) => AggressorSide::Buyer,
Some(OKXSide::Sell) => AggressorSide::Seller,
None => AggressorSide::NoAggressor,
}
}
pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
match liquidity {
Some(OKXExecType::Maker) => LiquiditySide::Maker,
Some(OKXExecType::Taker) => LiquiditySide::Taker,
_ => LiquiditySide::NoLiquiditySide,
}
}
pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
match current_qty {
Some(qty) if qty > 0 => PositionSide::Long,
Some(qty) if qty < 0 => PositionSide::Short,
_ => PositionSide::Flat,
}
}
pub fn parse_mark_price_update(
raw: &OKXMarkPrice,
instrument_id: InstrumentId,
price_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<MarkPriceUpdate> {
let ts_event = parse_millisecond_timestamp(raw.ts);
let price = parse_price(&raw.mark_px, price_precision)?;
Ok(MarkPriceUpdate::new(
instrument_id,
price,
ts_event,
ts_init,
))
}
pub fn parse_index_price_update(
raw: &OKXIndexTicker,
instrument_id: InstrumentId,
price_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<IndexPriceUpdate> {
let ts_event = parse_millisecond_timestamp(raw.ts);
let price = parse_price(&raw.idx_px, price_precision)?;
Ok(IndexPriceUpdate::new(
instrument_id,
price,
ts_event,
ts_init,
))
}
pub fn parse_funding_rate_msg(
msg: &OKXFundingRateMsg,
instrument_id: InstrumentId,
ts_init: UnixNanos,
) -> anyhow::Result<FundingRateUpdate> {
let funding_rate = msg
.funding_rate
.as_str()
.parse::<Decimal>()
.map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?;
let funding_time = parse_millisecond_timestamp(msg.funding_time);
let next_funding_time = parse_millisecond_timestamp(msg.next_funding_time);
let funding_interval_nanos =
next_funding_time
.duration_since(&funding_time)
.ok_or(anyhow::anyhow!(
"Invalid funding_interval, cannot be negative"
))?;
let funding_interval = u16::try_from(funding_interval_nanos / 60_000_000_000)
.context("funding_interval out of bounds")?;
let ts_event = parse_millisecond_timestamp(msg.ts);
Ok(FundingRateUpdate::new(
instrument_id,
funding_rate,
Some(funding_interval),
Some(funding_time),
ts_event,
ts_init,
))
}
pub fn parse_funding_rate(
raw: &OKXFundingRateHistory,
instrument_id: InstrumentId,
interval_millis: Option<u64>,
) -> anyhow::Result<FundingRateUpdate> {
let funding_rate =
Decimal::from_str(&raw.funding_rate).context("invalid funding_rate value")?;
let ts_event = UnixNanos::from(raw.funding_time * NANOSECONDS_IN_MILLISECOND);
let interval = interval_millis
.map(|ms| u16::try_from(ms / 60_000).context("interval milliseconds out of bounds"))
.transpose()?;
Ok(FundingRateUpdate::new(
instrument_id,
funding_rate,
interval,
None,
ts_event,
ts_event,
))
}
pub fn parse_trade_tick(
raw: &OKXTrade,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<TradeTick> {
let ts_event = parse_millisecond_timestamp(raw.ts);
let price = parse_price(&raw.px, price_precision)?;
let size = parse_quantity(&raw.sz, size_precision)?;
let aggressor: AggressorSide = raw.side.into();
let trade_id = TradeId::new(raw.trade_id);
TradeTick::new_checked(
instrument_id,
price,
size,
aggressor,
trade_id,
ts_event,
ts_init,
)
}
pub fn parse_candlestick(
raw: &OKXCandlestick,
bar_type: BarType,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<Bar> {
let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
let open = parse_price(&raw.1, price_precision)?;
let high = parse_price(&raw.2, price_precision)?;
let low = parse_price(&raw.3, price_precision)?;
let close = parse_price(&raw.4, price_precision)?;
let volume = parse_quantity(&raw.5, size_precision)?;
Ok(Bar::new(
bar_type, open, high, low, close, volume, ts_event, ts_init,
))
}
#[expect(clippy::too_many_lines)]
pub fn parse_order_status_report(
order: &OKXOrderHistory,
account_id: AccountId,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
match order.category {
OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation => {
log::warn!(
"Liquidation order (HTTP history): ord_id={}, category={:?}, inst_id={}, state={:?}, side={:?}, sz={}, fill_sz={}",
order.ord_id,
order.category,
instrument_id,
order.state,
order.side,
order.sz,
order.acc_fill_sz,
);
}
OKXOrderCategory::Adl => {
log::warn!(
"ADL (Auto-Deleveraging) order (HTTP history): ord_id={}, inst_id={}, state={:?}, side={:?}, sz={}, fill_sz={}",
order.ord_id,
instrument_id,
order.state,
order.side,
order.sz,
order.acc_fill_sz,
);
}
_ => {}
}
let okx_ord_type: OKXOrderType = order.ord_type;
let order_type =
determine_order_type_with_alt(okx_ord_type, &order.px, &order.px_vol, &order.px_usd);
let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
let is_quote_qty_heuristic = order.tgt_ccy.is_none()
&& (order.inst_type == OKXInstrumentType::Spot
|| order.inst_type == OKXInstrumentType::Margin)
&& order.side == OKXSide::Buy
&& order_type == OrderType::Market;
let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
let sz_quote_dec = Decimal::from_str(&order.sz).ok();
let conversion_price_dec = if !order.px.is_empty() && order.px != "0" {
Decimal::from_str(&order.px).ok()
} else if !order.avg_px.is_empty() && order.avg_px != "0" {
Decimal::from_str(&order.avg_px).ok()
} else {
log::warn!(
"No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
order.ord_id.as_str(),
order.px,
order.avg_px
);
None
};
let quantity_base = if let (Some(sz), Some(price)) = (sz_quote_dec, conversion_price_dec) {
if price.is_zero() {
log::warn!(
"Cannot convert quote quantity with zero price: ord_id={}, sz={}, using sz as-is",
order.ord_id.as_str(),
order.sz
);
Quantity::from_str(&order.sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
order.ord_id.as_str(),
order.sz
)
})?
} else {
let quantity_dec = sz / price;
Quantity::from_decimal_dp(quantity_dec, size_precision).map_err(|e| {
anyhow::anyhow!(
"Failed to convert quote-to-base quantity for ord_id={}, sz={sz}, price={price}, quantity_dec={quantity_dec}: {e}",
order.ord_id.as_str()
)
})?
}
} else {
log::warn!(
"Cannot convert quote quantity to base without price, using raw sz: \
ord_id={}, sz={}, px='{}', avg_px='{}'",
order.ord_id.as_str(),
order.sz,
order.px,
order.avg_px
);
Quantity::from_str(&order.sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
order.ord_id.as_str(),
order.sz
)
})?
};
let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
anyhow::anyhow!(
"Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
order.ord_id.as_str(),
order.acc_fill_sz
)
})?;
(quantity_base, filled_qty_dec)
} else {
let quantity_dec = parse_quantity(&order.sz, size_precision).map_err(|e| {
anyhow::anyhow!(
"Failed to parse base quantity for ord_id={}, sz='{}': {e}",
order.ord_id.as_str(),
order.sz
)
})?;
let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
anyhow::anyhow!(
"Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
order.ord_id.as_str(),
order.acc_fill_sz
)
})?;
(quantity_dec, filled_qty_dec)
};
let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
&& order.state == OKXOrderStatus::Filled
&& filled_qty.is_positive()
{
(filled_qty, filled_qty)
} else {
(quantity, filled_qty)
};
let order_side: OrderSide = order.side.into();
let okx_status: OKXOrderStatus = order.state;
let order_status: OrderStatus = okx_status.into();
let time_in_force = match okx_ord_type {
OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
_ => TimeInForce::Gtc,
};
let mut client_order_id = if order.cl_ord_id.is_empty() {
None
} else {
Some(ClientOrderId::new(order.cl_ord_id.as_str()))
};
let mut linked_ids = Vec::new();
if let Some(algo_cl_ord_id) = order
.algo_cl_ord_id
.as_ref()
.filter(|value| !value.as_str().is_empty())
{
let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
match &client_order_id {
Some(existing) if existing == &algo_client_id => {}
Some(_) => linked_ids.push(algo_client_id),
None => client_order_id = Some(algo_client_id),
}
}
if let Some(attach_algo_cl_ord_id) = order
.attach_algo_cl_ord_id
.as_ref()
.filter(|value| !value.as_str().is_empty())
{
let attach_client_id = ClientOrderId::new(attach_algo_cl_ord_id.as_str());
match &client_order_id {
Some(existing) if existing == &attach_client_id => {}
_ if linked_ids.contains(&attach_client_id) => {}
_ => linked_ids.push(attach_client_id),
}
}
for attach_algo in &order.attach_algo_ords {
if attach_algo.attach_algo_cl_ord_id.is_empty() {
continue;
}
let attach_client_id = ClientOrderId::new(attach_algo.attach_algo_cl_ord_id.as_str());
match &client_order_id {
Some(existing) if existing == &attach_client_id => {}
_ if linked_ids.contains(&attach_client_id) => {}
_ => linked_ids.push(attach_client_id),
}
}
let venue_order_id = if order.ord_id.is_empty() {
if let Some(algo_id) = order
.algo_id
.as_ref()
.filter(|value| !value.as_str().is_empty())
{
VenueOrderId::new(algo_id.as_str())
} else if !order.cl_ord_id.is_empty() {
VenueOrderId::new(order.cl_ord_id.as_str())
} else {
let synthetic_id = format!("{}:{}", account_id, order.c_time);
VenueOrderId::new(&synthetic_id)
}
} else {
VenueOrderId::new(order.ord_id.as_str())
};
let ts_accepted = parse_millisecond_timestamp(order.c_time);
let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
client_order_id,
venue_order_id,
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_last,
ts_init,
None,
);
if !order.px.is_empty()
&& let Ok(decimal) = Decimal::from_str(&order.px)
&& let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
{
report = report.with_price(price);
}
if !order.avg_px.is_empty()
&& let Ok(decimal) = Decimal::from_str(&order.avg_px)
{
report.avg_px = Some(decimal);
}
if matches!(order.ord_type, OKXOrderType::PostOnly | OKXOrderType::Rpi) {
report = report.with_post_only(true);
}
if order.reduce_only == "true" {
report = report.with_reduce_only(true);
}
if !linked_ids.is_empty() {
report = report.with_linked_order_ids(linked_ids);
}
Ok(report)
}
pub fn parse_spot_margin_position_from_balance(
balance: &OKXBalanceDetail,
account_id: AccountId,
instrument_id: InstrumentId,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<Option<PositionStatusReport>> {
let liab_str = if balance.liab.trim().is_empty() {
"0"
} else {
balance.liab.trim()
};
let spot_in_use_str = if balance.spot_in_use_amt.trim().is_empty() {
"0"
} else {
balance.spot_in_use_amt.trim()
};
let liab_dec = Decimal::from_str(liab_str)
.map_err(|e| anyhow::anyhow!("Failed to parse liab '{liab_str}': {e}"))?;
let spot_in_use_dec = Decimal::from_str(spot_in_use_str)
.map_err(|e| anyhow::anyhow!("Failed to parse spotInUseAmt '{spot_in_use_str}': {e}"))?;
if liab_dec.is_zero() && spot_in_use_dec.is_zero() {
return Ok(None);
}
if spot_in_use_dec.is_zero() {
return Ok(None);
}
let (position_side, quantity_dec) = if spot_in_use_dec.is_sign_negative() {
(PositionSide::Short, spot_in_use_dec.abs())
} else {
(PositionSide::Long, spot_in_use_dec)
};
let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)
.map_err(|e| anyhow::anyhow!("Failed to create quantity from {quantity_dec}: {e}"))?;
let ts_last = parse_millisecond_timestamp(balance.u_time);
Ok(Some(PositionStatusReport::new(
account_id,
instrument_id,
position_side.as_specified(),
quantity,
ts_last,
ts_init,
None, None, None, )))
}
pub fn parse_position_status_report(
position: &OKXPosition,
account_id: AccountId,
instrument_id: InstrumentId,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<PositionStatusReport> {
let pos_dec = Decimal::from_str(&position.pos).map_err(|e| {
anyhow::anyhow!(
"Failed to parse position quantity '{}' for instrument {}: {e:?}",
position.pos,
instrument_id
)
})?;
let (position_side, quantity_dec) = if position.inst_type == OKXInstrumentType::Spot
|| position.inst_type == OKXInstrumentType::Margin
{
let (base_ccy, quote_ccy) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())?;
let pos_ccy = position.pos_ccy.as_str();
if pos_ccy.is_empty() || pos_dec.is_zero() {
(PositionSide::Flat, Decimal::ZERO)
} else if pos_ccy == base_ccy {
(PositionSide::Long, pos_dec.abs())
} else if pos_ccy == quote_ccy {
let avg_px_str = if position.avg_px.is_empty() {
&position.mark_px
} else {
&position.avg_px
};
let avg_px_dec = Decimal::from_str(avg_px_str)?;
if avg_px_dec.is_zero() {
anyhow::bail!(
"Cannot convert SHORT position from quote to base: avg_px is zero for {instrument_id}"
);
}
let quantity_dec = (pos_dec.abs() / avg_px_dec).round_dp(size_precision as u32);
(PositionSide::Short, quantity_dec)
} else {
anyhow::bail!(
"Unknown position currency '{pos_ccy}' for instrument {instrument_id} (base={base_ccy}, quote={quote_ccy})"
);
}
} else {
let side = match position.pos_side {
OKXPositionSide::Net | OKXPositionSide::None => {
if pos_dec.is_sign_positive() && !pos_dec.is_zero() {
PositionSide::Long
} else if pos_dec.is_sign_negative() {
PositionSide::Short
} else {
PositionSide::Flat
}
}
OKXPositionSide::Long => {
PositionSide::Long
}
OKXPositionSide::Short => {
PositionSide::Short
}
};
(side, pos_dec.abs())
};
let position_side = position_side.as_specified();
let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)?;
let venue_position_id = match position.pos_side {
OKXPositionSide::Long => {
position
.pos_id
.map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
}
OKXPositionSide::Short => {
position
.pos_id
.map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
}
OKXPositionSide::Net | OKXPositionSide::None => {
None
}
};
let avg_px_open = if position.avg_px.is_empty() {
None
} else {
Some(Decimal::from_str(&position.avg_px)?)
};
let ts_last = parse_millisecond_timestamp(position.u_time);
Ok(PositionStatusReport::new(
account_id,
instrument_id,
position_side,
quantity,
ts_last,
ts_init,
None, venue_position_id,
avg_px_open,
))
}
pub fn parse_fill_report(
detail: &OKXTransactionDetail,
account_id: AccountId,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<FillReport> {
let client_order_id = if detail.cl_ord_id.is_empty() {
None
} else {
Some(ClientOrderId::new(detail.cl_ord_id))
};
let venue_order_id = VenueOrderId::new(detail.ord_id);
let trade_id = TradeId::new(detail.trade_id);
let order_side: OrderSide = detail.side.into();
let last_px = parse_price(&detail.fill_px, price_precision)?;
let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
let fee_dec = Decimal::from_str(detail.fee.as_deref().unwrap_or("0"))?;
let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
format!("fill report for instrument_id={instrument_id}")
});
let commission = Money::from_decimal(-fee_dec, fee_currency)?;
let liquidity_side: LiquiditySide = detail.exec_type.into();
let ts_event = parse_millisecond_timestamp(detail.ts);
Ok(FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission,
liquidity_side,
client_order_id,
None, ts_event,
ts_init,
None, ))
}
pub fn parse_spread_order_status_report(
order: &OKXSpreadOrder,
account_id: AccountId,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let order_type = determine_order_type(order.ord_type, &order.px);
let quantity = parse_quantity(&order.sz, size_precision)?;
let filled_qty = parse_quantity(&order.acc_fill_sz, size_precision)?;
let order_side: OrderSide = order.side.into();
let order_status: OrderStatus = order.state.into();
let time_in_force = match order.ord_type {
OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
_ => TimeInForce::Gtc,
};
let client_order_id = if order.cl_ord_id.is_empty() {
None
} else {
Some(ClientOrderId::new(order.cl_ord_id.as_str()))
};
let venue_order_id = if order.ord_id.is_empty() {
VenueOrderId::new(order.cl_ord_id.as_str())
} else {
VenueOrderId::new(order.ord_id.as_str())
};
let ts_accepted = order.c_time.map_or(ts_init, parse_millisecond_timestamp);
let ts_last = order
.u_time
.or(order.c_time)
.map_or(ts_accepted, parse_millisecond_timestamp);
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
client_order_id,
venue_order_id,
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_last,
ts_init,
None,
);
if !order.px.is_empty()
&& let Ok(decimal) = Decimal::from_str(&order.px)
&& let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
{
report = report.with_price(price);
}
if !order.avg_px.is_empty()
&& let Ok(decimal) = Decimal::from_str(&order.avg_px)
{
report.avg_px = Some(decimal);
}
if matches!(order.ord_type, OKXOrderType::PostOnly | OKXOrderType::Rpi) {
report = report.with_post_only(true);
}
Ok(report)
}
pub fn parse_spread_fill_report(
detail: &OKXSpreadTrade,
account_id: AccountId,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> anyhow::Result<FillReport> {
let client_order_id = if detail.cl_ord_id.is_empty() {
None
} else {
Some(ClientOrderId::new(detail.cl_ord_id.as_str()))
};
let venue_order_id = VenueOrderId::new(detail.ord_id.as_str());
let trade_id = TradeId::new(detail.trade_id.as_str());
let order_side: OrderSide = detail.side.into();
let last_px = parse_price(&detail.fill_px, price_precision)?;
let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
let fee_dec = Decimal::from_str(detail.fee.as_deref().unwrap_or("0"))?;
let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
format!("spread fill report for instrument_id={instrument_id}")
});
let commission = Money::from_decimal(-fee_dec, fee_currency)?;
let liquidity_side: LiquiditySide = detail.exec_type.into();
let ts_event = parse_millisecond_timestamp(detail.ts);
Ok(FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission,
liquidity_side,
client_order_id,
None,
ts_event,
ts_init,
None,
))
}
pub fn parse_message_vec<T, R, F, W>(
data: serde_json::Value,
parser: F,
wrapper: W,
) -> anyhow::Result<Vec<Data>>
where
T: DeserializeOwned,
F: Fn(&T) -> anyhow::Result<R>,
W: Fn(R) -> Data,
{
let messages: Vec<T> =
serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Expected array payload: {e}"))?;
let mut results = Vec::with_capacity(messages.len());
for message in &messages {
let parsed = parser(message)?;
results.push(wrapper(parsed));
}
Ok(results)
}
pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
let channel = match bar_spec {
BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
_ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
};
Ok(channel)
}
pub fn bar_spec_as_okx_mark_price_channel(
bar_spec: BarSpecification,
) -> anyhow::Result<OKXWsChannel> {
let channel = match bar_spec {
BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
_ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
};
Ok(channel)
}
pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
let timeframe = match bar_spec {
BAR_SPEC_1_SECOND_LAST => "1s",
BAR_SPEC_1_MINUTE_LAST => "1m",
BAR_SPEC_3_MINUTE_LAST => "3m",
BAR_SPEC_5_MINUTE_LAST => "5m",
BAR_SPEC_15_MINUTE_LAST => "15m",
BAR_SPEC_30_MINUTE_LAST => "30m",
BAR_SPEC_1_HOUR_LAST => "1H",
BAR_SPEC_2_HOUR_LAST => "2H",
BAR_SPEC_4_HOUR_LAST => "4H",
BAR_SPEC_6_HOUR_LAST => "6H",
BAR_SPEC_12_HOUR_LAST => "12H",
BAR_SPEC_1_DAY_LAST => "1D",
BAR_SPEC_2_DAY_LAST => "2D",
BAR_SPEC_3_DAY_LAST => "3D",
BAR_SPEC_5_DAY_LAST => "5D",
BAR_SPEC_1_WEEK_LAST => "1W",
BAR_SPEC_1_MONTH_LAST => "1M",
BAR_SPEC_3_MONTH_LAST => "3M",
BAR_SPEC_6_MONTH_LAST => "6M",
BAR_SPEC_12_MONTH_LAST => "1Y",
_ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
};
Ok(timeframe)
}
pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
let bar_spec = match timeframe {
"1s" => BAR_SPEC_1_SECOND_LAST,
"1m" => BAR_SPEC_1_MINUTE_LAST,
"3m" => BAR_SPEC_3_MINUTE_LAST,
"5m" => BAR_SPEC_5_MINUTE_LAST,
"15m" => BAR_SPEC_15_MINUTE_LAST,
"30m" => BAR_SPEC_30_MINUTE_LAST,
"1H" => BAR_SPEC_1_HOUR_LAST,
"2H" => BAR_SPEC_2_HOUR_LAST,
"4H" => BAR_SPEC_4_HOUR_LAST,
"6H" => BAR_SPEC_6_HOUR_LAST,
"12H" => BAR_SPEC_12_HOUR_LAST,
"1D" => BAR_SPEC_1_DAY_LAST,
"2D" => BAR_SPEC_2_DAY_LAST,
"3D" => BAR_SPEC_3_DAY_LAST,
"5D" => BAR_SPEC_5_DAY_LAST,
"1W" => BAR_SPEC_1_WEEK_LAST,
"1M" => BAR_SPEC_1_MONTH_LAST,
"3M" => BAR_SPEC_3_MONTH_LAST,
"6M" => BAR_SPEC_6_MONTH_LAST,
"1Y" => BAR_SPEC_12_MONTH_LAST,
_ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
};
Ok(bar_spec)
}
pub fn okx_bar_type_from_timeframe(
instrument_id: InstrumentId,
timeframe: &str,
) -> anyhow::Result<BarType> {
let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
Ok(BarType::new(
instrument_id,
bar_spec,
AggregationSource::External,
))
}
pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
use OKXWsChannel::*;
match channel {
Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
_ => None,
}
}
pub fn parse_instrument_any(
instrument: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<Option<InstrumentAny>> {
match instrument.inst_type {
OKXInstrumentType::Spot => parse_spot_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Margin => parse_spot_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Swap => parse_swap_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Futures => parse_futures_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Option => parse_option_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Events => parse_event_contract_instrument(
instrument,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
.map(Some),
OKXInstrumentType::Any => Ok(None),
}
}
pub fn parse_spread_instrument(
definition: &OKXSpread,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
if definition.tick_sz.is_empty() {
anyhow::bail!("`tick_sz` is empty for {}", definition.sprd_id);
}
if definition.lot_sz.is_empty() {
anyhow::bail!("`lot_sz` is empty for {}", definition.sprd_id);
}
let context = format!("SPREAD instrument {}", definition.sprd_id);
let instrument_id = parse_instrument_id(definition.sprd_id);
let raw_symbol = Symbol::from_ustr_unchecked(definition.sprd_id);
let underlying =
Currency::get_or_create_crypto_with_context(definition.base_ccy, Some(&context));
let quote_currency =
Currency::get_or_create_crypto_with_context(definition.quote_ccy, Some(&context));
let settlement_currency = spread_settlement_currency(definition, underlying, quote_currency);
let is_inverse = matches!(definition.sprd_type, OKXSpreadType::Inverse);
let activation_ns = definition
.list_time
.map(parse_millisecond_timestamp)
.ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.sprd_id))?;
let expiration_ns = definition
.exp_time
.map(parse_millisecond_timestamp)
.unwrap_or_default();
let ts_event = definition
.u_time
.map_or(ts_init, parse_millisecond_timestamp);
let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `tick_sz` '{}' for {}: {e}",
definition.tick_sz,
definition.sprd_id
)
})?;
let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `lot_sz` '{}' for {}: {e}",
definition.lot_sz,
definition.sprd_id
)
})?;
let min_quantity = if definition.min_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `min_sz` '{}' for {}: {e}",
definition.min_sz,
definition.sprd_id
)
})?)
};
let info = Some(build_spread_info(definition));
if spread_has_option_leg(definition) {
let instrument = CryptoOptionSpread::new(
instrument_id,
raw_symbol,
underlying,
quote_currency,
settlement_currency,
is_inverse,
Ustr::from(spread_type_literal(definition.sprd_type)),
activation_ns,
expiration_ns,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
None,
Some(size_increment),
None,
min_quantity,
None,
None,
None,
None,
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
info,
ts_event,
ts_init,
);
return Ok(InstrumentAny::CryptoOptionSpread(instrument));
}
let instrument = CryptoFuturesSpread::new(
instrument_id,
raw_symbol,
underlying,
quote_currency,
settlement_currency,
is_inverse,
Ustr::from(spread_type_literal(definition.sprd_type)),
activation_ns,
expiration_ns,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
None,
Some(size_increment),
None,
min_quantity,
None,
None,
None,
None,
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
info,
ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoFuturesSpread(instrument))
}
fn spread_has_option_leg(definition: &OKXSpread) -> bool {
definition.legs.iter().any(|leg| {
okx_instrument_type_from_symbol(leg.inst_id.as_str()) == OKXInstrumentType::Option
})
}
fn spread_settlement_currency(
definition: &OKXSpread,
underlying: Currency,
quote_currency: Currency,
) -> Currency {
match definition.sprd_type {
OKXSpreadType::Inverse => underlying,
OKXSpreadType::Linear | OKXSpreadType::Hybrid | OKXSpreadType::Unknown => quote_currency,
}
}
fn build_spread_info(definition: &OKXSpread) -> Params {
let mut info = Params::new();
info.insert(
"okx_sprd_id".to_string(),
serde_json::json!(definition.sprd_id),
);
info.insert(
"okx_sprd_type".to_string(),
serde_json::json!(spread_type_literal(definition.sprd_type)),
);
info.insert(
"okx_spread_state".to_string(),
serde_json::json!(spread_state_literal(definition.state)),
);
info.insert(
"okx_base_ccy".to_string(),
serde_json::json!(definition.base_ccy),
);
info.insert(
"okx_sz_ccy".to_string(),
serde_json::json!(definition.sz_ccy),
);
info.insert(
"okx_quote_ccy".to_string(),
serde_json::json!(definition.quote_ccy),
);
info.insert(
"okx_list_time".to_string(),
serde_json::json!(definition.list_time),
);
info.insert(
"okx_exp_time".to_string(),
serde_json::json!(definition.exp_time),
);
info.insert(
"okx_u_time".to_string(),
serde_json::json!(definition.u_time),
);
let legs = definition
.legs
.iter()
.map(|leg| {
let leg_id = parse_instrument_id(leg.inst_id);
serde_json::json!({
"inst_id": leg.inst_id,
"instrument_id": leg_id.to_string(),
"side": side_literal(leg.side),
"ratio": leg_ratio(leg.side),
})
})
.collect::<Vec<_>>();
info.insert("okx_spread_legs".to_string(), serde_json::json!(legs));
info
}
fn spread_type_literal(spread_type: OKXSpreadType) -> &'static str {
match spread_type {
OKXSpreadType::Linear => "linear",
OKXSpreadType::Inverse => "inverse",
OKXSpreadType::Hybrid => "hybrid",
OKXSpreadType::Unknown => "unknown",
}
}
fn spread_state_literal(state: OKXSpreadState) -> &'static str {
match state {
OKXSpreadState::Live => "live",
OKXSpreadState::Suspend => "suspend",
OKXSpreadState::Expired => "expired",
OKXSpreadState::Unknown => "unknown",
}
}
fn side_literal(side: OKXSide) -> &'static str {
match side {
OKXSide::Buy => "buy",
OKXSide::Sell => "sell",
}
}
fn leg_ratio(side: OKXSide) -> i8 {
match side {
OKXSide::Buy => 1,
OKXSide::Sell => -1,
}
}
#[derive(Debug)]
struct CommonInstrumentData {
instrument_id: InstrumentId,
raw_symbol: Symbol,
price_increment: Price,
size_increment: Quantity,
lot_size: Option<Quantity>,
max_quantity: Option<Quantity>,
min_quantity: Option<Quantity>,
max_notional: Option<Money>,
min_notional: Option<Money>,
max_price: Option<Price>,
min_price: Option<Price>,
}
struct MarginAndFees {
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
}
fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
return Ok(None);
}
let mult_value = if definition.ct_mult.is_empty() {
Decimal::ONE
} else {
Decimal::from_str(&definition.ct_mult).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `ct_mult` '{}' for {}: {e}",
definition.ct_mult,
definition.inst_id
)
})?
};
let val_value = if definition.ct_val.is_empty() {
Decimal::ONE
} else {
Decimal::from_str(&definition.ct_val).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `ct_val` '{}' for {}: {e}",
definition.ct_val,
definition.inst_id
)
})?
};
let product = mult_value * val_value;
Ok(Some(Quantity::from(product.to_string())))
}
trait InstrumentParser {
fn parse_specific_fields(
&self,
definition: &OKXInstrument,
common: CommonInstrumentData,
margin_fees: MarginAndFees,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny>;
}
fn parse_common_instrument_data(
definition: &OKXInstrument,
) -> anyhow::Result<CommonInstrumentData> {
let instrument_id = parse_instrument_id(definition.inst_id);
let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
if definition.tick_sz.is_empty() {
anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
}
let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `tick_sz` '{}' into Price for {}: {e}",
definition.tick_sz,
definition.inst_id,
)
})?;
if definition.lot_sz.is_empty() {
anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
}
let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `lot_sz` '{}' for {}: {e}",
definition.lot_sz,
definition.inst_id,
)
})?;
let lot_size = Some(size_increment);
let max_quantity = if definition.max_mkt_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `max_mkt_sz` '{}' for {}: {e}",
definition.max_mkt_sz,
definition.inst_id,
)
})?)
};
let min_quantity = if definition.min_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `min_sz` '{}' for {}: {e}",
definition.min_sz,
definition.inst_id,
)
})?)
};
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = None; let min_price = None;
Ok(CommonInstrumentData {
instrument_id,
raw_symbol,
price_increment,
size_increment,
lot_size,
max_quantity,
min_quantity,
max_notional,
min_notional,
max_price,
min_price,
})
}
fn parse_instrument_with_parser<P: InstrumentParser>(
definition: &OKXInstrument,
parser: &P,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let common = parse_common_instrument_data(definition)?;
parser.parse_specific_fields(
definition,
common,
MarginAndFees {
margin_init,
margin_maint,
maker_fee,
taker_fee,
},
ts_init,
)
}
struct SpotInstrumentParser;
impl InstrumentParser for SpotInstrumentParser {
fn parse_specific_fields(
&self,
definition: &OKXInstrument,
common: CommonInstrumentData,
margin_fees: MarginAndFees,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
let base_currency =
Currency::get_or_create_crypto_with_context(definition.base_ccy, Some(&context));
let quote_currency =
Currency::get_or_create_crypto_with_context(definition.quote_ccy, Some(&context));
let multiplier = parse_multiplier_product(definition)?;
let info = build_price_limit_info(definition);
let instrument = CurrencyPair::new(
common.instrument_id,
common.raw_symbol,
base_currency,
quote_currency,
common.price_increment.precision,
common.size_increment.precision,
common.price_increment,
common.size_increment,
multiplier,
common.lot_size,
common.max_quantity,
common.min_quantity,
common.max_notional,
common.min_notional,
common.max_price,
common.min_price,
margin_fees.margin_init,
margin_fees.margin_maint,
margin_fees.maker_fee,
margin_fees.taker_fee,
None,
info,
ts_init,
ts_init,
);
Ok(InstrumentAny::CurrencyPair(instrument))
}
}
pub fn parse_spot_instrument(
definition: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
parse_instrument_with_parser(
definition,
&SpotInstrumentParser,
margin_init,
margin_maint,
maker_fee,
taker_fee,
ts_init,
)
}
fn validate_underlying(inst_id: Ustr, uly: Ustr) -> anyhow::Result<()> {
if uly.is_empty() {
anyhow::bail!(
"Empty underlying for {inst_id}: instrument may be pre-open or misconfigured"
);
}
Ok(())
}
pub fn parse_swap_instrument(
definition: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
validate_underlying(definition.inst_id, definition.uly)?;
let context = format!("SWAP instrument {}", definition.inst_id);
let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
anyhow::anyhow!(
"Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
definition.uly,
definition.inst_id
)
})?;
let instrument_id = parse_instrument_id(definition.inst_id);
let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
let base_currency = Currency::get_or_create_crypto_with_context(base_currency, Some(&context));
let quote_currency =
Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
let settlement_currency =
Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
let is_inverse = match definition.ct_type {
OKXContractType::Linear => false,
OKXContractType::Inverse => true,
OKXContractType::None => {
anyhow::bail!(
"Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
definition.ct_type,
definition.inst_id
)
}
};
if definition.tick_sz.is_empty() {
anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
}
let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `tick_sz` '{}' into Price for {}: {e}",
definition.tick_sz,
definition.inst_id
)
})?;
if definition.lot_sz.is_empty() {
anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
}
let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `lot_sz` '{}' for {}: {e}",
definition.lot_sz,
definition.inst_id
)
})?;
let multiplier = parse_multiplier_product(definition)?;
let lot_size = Some(size_increment);
let max_quantity = if definition.max_mkt_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `max_mkt_sz` '{}' for {}: {e}",
definition.max_mkt_sz,
definition.inst_id
)
})?)
};
let min_quantity = if definition.min_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `min_sz` '{}' for {}: {e}",
definition.min_sz,
definition.inst_id
)
})?)
};
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = None; let min_price = None; let info = build_price_limit_info(definition);
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,
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
info,
ts_init, ts_init,
);
Ok(InstrumentAny::CryptoPerpetual(instrument))
}
pub fn parse_futures_instrument(
definition: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
validate_underlying(definition.inst_id, definition.uly)?;
let context = format!("FUTURES instrument {}", definition.inst_id);
let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
anyhow::anyhow!(
"Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
definition.uly,
definition.inst_id
)
})?;
let instrument_id = parse_instrument_id(definition.inst_id);
let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
let underlying = Currency::get_or_create_crypto_with_context(definition.uly, Some(&context));
let quote_currency =
Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
let settlement_currency =
Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
let is_inverse = match definition.ct_type {
OKXContractType::Linear => false,
OKXContractType::Inverse => true,
OKXContractType::None => {
anyhow::bail!(
"Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
definition.ct_type,
definition.inst_id
)
}
};
let listing_time = definition
.list_time
.ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
let expiry_time = definition
.exp_time
.ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
let activation_ns = parse_millisecond_timestamp(listing_time);
let expiration_ns = parse_millisecond_timestamp(expiry_time);
if definition.tick_sz.is_empty() {
anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
}
let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `tick_sz` '{}' for {}: {e}",
definition.tick_sz,
definition.inst_id
)
})?;
if definition.lot_sz.is_empty() {
anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
}
let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `lot_sz` '{}' for {}: {e}",
definition.lot_sz,
definition.inst_id
)
})?;
let multiplier = parse_multiplier_product(definition)?;
let lot_size = Some(size_increment);
let max_quantity = if definition.max_mkt_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `max_mkt_sz` '{}' for {}: {e}",
definition.max_mkt_sz,
definition.inst_id
)
})?)
};
let min_quantity = if definition.min_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `min_sz` '{}' for {}: {e}",
definition.min_sz,
definition.inst_id
)
})?)
};
let max_notional: Option<Money> = None;
let min_notional: Option<Money> = None;
let max_price = None; let min_price = None;
let info = build_futures_info(definition);
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,
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
info,
ts_init, ts_init,
);
Ok(InstrumentAny::CryptoFuture(instrument))
}
#[must_use]
pub fn is_xperp_rule_type(rule_type: &str) -> bool {
rule_type.eq_ignore_ascii_case("xperp")
}
fn build_futures_info(definition: &OKXInstrument) -> Option<Params> {
let mut info = build_price_limit_info(definition).unwrap_or_default();
if !definition.rule_type.is_empty() {
info.insert(
"rule_type".to_string(),
serde_json::Value::String(definition.rule_type.clone()),
);
}
(!info.is_empty()).then_some(info)
}
fn build_price_limit_info(definition: &OKXInstrument) -> Option<Params> {
let mut info = Params::new();
insert_non_empty_info(
&mut info,
"okx_init_px_lmt_pct",
&definition.init_px_lmt_pct,
);
insert_non_empty_info(
&mut info,
"okx_float_px_lmt_pct",
&definition.float_px_lmt_pct,
);
insert_non_empty_info(&mut info, "okx_max_px_lmt_pct", &definition.max_px_lmt_pct);
if let Some(rpi_min_level) = definition.rpi_min_level {
info.insert(
"okx_rpi_min_level".to_string(),
serde_json::Value::from(rpi_min_level),
);
}
if let Some(rpi_min_px_band) = definition.rpi_min_px_band {
info.insert(
"okx_rpi_min_px_band".to_string(),
serde_json::Value::String(rpi_min_px_band.to_string()),
);
}
(!info.is_empty()).then_some(info)
}
fn insert_non_empty_info(info: &mut Params, key: &str, value: &str) {
if !value.is_empty() {
info.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
}
pub fn parse_option_instrument(
definition: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
validate_underlying(definition.inst_id, definition.uly)?;
let context = format!("OPTION instrument {}", definition.inst_id);
let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
anyhow::anyhow!(
"Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
definition.uly,
definition.inst_id
)
})?;
let instrument_id = parse_instrument_id(definition.inst_id);
let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
let underlying = Currency::get_or_create_crypto_with_context(underlying_str, Some(&context));
let option_kind: OptionKind = OptionKind::try_from(definition.opt_type).map_err(|kind| {
anyhow::anyhow!(
"Unsupported `optType` '{kind:?}' for {}: cannot map to Nautilus OptionKind",
definition.inst_id
)
})?;
let strike_price = Price::from_str(&definition.stk).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `stk` '{}' for {}: {e}",
definition.stk,
definition.inst_id
)
})?;
let quote_currency = Currency::get_or_create_crypto_with_context(quote_ccy_str, Some(&context));
let settlement_currency =
Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
let is_inverse = if definition.ct_type == OKXContractType::None {
settlement_currency == underlying
} else {
matches!(definition.ct_type, OKXContractType::Inverse)
};
let listing_time = definition
.list_time
.ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
let expiry_time = definition
.exp_time
.ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
let activation_ns = parse_millisecond_timestamp(listing_time);
let expiration_ns = parse_millisecond_timestamp(expiry_time);
if definition.tick_sz.is_empty() {
anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
}
let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `tick_sz` '{}' for {}: {e}",
definition.tick_sz,
definition.inst_id
)
})?;
if definition.lot_sz.is_empty() {
anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
}
let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `lot_sz` '{}' for {}: {e}",
definition.lot_sz,
definition.inst_id
)
})?;
let multiplier = parse_multiplier_product(definition)?;
let lot_size = size_increment;
let max_quantity = if definition.max_mkt_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `max_mkt_sz` '{}' for {}: {e}",
definition.max_mkt_sz,
definition.inst_id
)
})?)
};
let min_quantity = if definition.min_sz.is_empty() {
None
} else {
Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
anyhow::anyhow!(
"Failed to parse `min_sz` '{}' for {}: {e}",
definition.min_sz,
definition.inst_id
)
})?)
};
let max_notional = None;
let min_notional = None;
let max_price = None;
let min_price = None;
let instrument = CryptoOption::new(
instrument_id,
raw_symbol,
underlying,
quote_currency,
settlement_currency,
is_inverse,
option_kind,
strike_price,
activation_ns,
expiration_ns,
price_increment.precision,
size_increment.precision,
price_increment,
size_increment,
multiplier,
Some(lot_size),
max_quantity,
min_quantity,
max_notional,
min_notional,
max_price,
min_price,
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
None,
ts_init,
ts_init,
);
Ok(InstrumentAny::CryptoOption(instrument))
}
fn okx_inst_category_to_asset_class(category: Option<OKXInstrumentCategory>) -> AssetClass {
match category {
Some(OKXInstrumentCategory::Crypto) => AssetClass::Cryptocurrency,
Some(OKXInstrumentCategory::Equity) => AssetClass::Equity,
Some(OKXInstrumentCategory::Commodity) => AssetClass::Commodity,
Some(OKXInstrumentCategory::Fx) => AssetClass::FX,
Some(OKXInstrumentCategory::Debt) => AssetClass::Debt,
Some(OKXInstrumentCategory::Unknown) | None => AssetClass::Alternative,
}
}
fn parse_event_contract_currency(definition: &OKXInstrument) -> anyhow::Result<Currency> {
let context = format!("EVENTS instrument {}", definition.inst_id);
let currency = if !definition.settle_ccy.is_empty() {
definition.settle_ccy
} else if !definition.quote_ccy.is_empty() {
definition.quote_ccy
} else {
anyhow::bail!(
"`settle_ccy` or `quote_ccy` is required for EVENTS instrument {}",
definition.inst_id
);
};
Ok(Currency::get_or_create_crypto_with_context(
currency,
Some(&context),
))
}
fn build_event_contract_info(definition: &OKXInstrument) -> anyhow::Result<Params> {
let mut map = serde_json::Map::new();
if let Some(series_id) = definition.series_id {
map.insert(
"series_id".to_string(),
serde_json::Value::String(series_id.to_string()),
);
}
if let Some(inst_category) = definition.inst_category {
let code = inst_category.as_ref();
if !code.is_empty() {
map.insert(
"inst_category".to_string(),
serde_json::Value::String(code.to_string()),
);
}
}
if let Some(inst_id_code) = definition.inst_id_code {
map.insert(
"inst_id_code".to_string(),
serde_json::Value::Number(inst_id_code.into()),
);
}
map.insert(
"state".to_string(),
serde_json::Value::String(definition.state.to_string()),
);
map.insert(
"rule_type".to_string(),
serde_json::Value::String(definition.rule_type.clone()),
);
Ok(serde_json::from_value(serde_json::Value::Object(map))?)
}
pub fn parse_event_contract_instrument(
definition: &OKXInstrument,
margin_init: Option<Decimal>,
margin_maint: Option<Decimal>,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let common = parse_common_instrument_data(definition)?;
let currency = parse_event_contract_currency(definition)?;
let activation_ns = definition
.list_time
.map(parse_millisecond_timestamp)
.unwrap_or_default();
let expiration_ns = definition
.exp_time
.map(parse_millisecond_timestamp)
.unwrap_or_default();
let asset_class = okx_inst_category_to_asset_class(definition.inst_category);
let info = build_event_contract_info(definition)?;
let instrument = BinaryOption::new_checked(
common.instrument_id,
common.raw_symbol,
asset_class,
currency,
activation_ns,
expiration_ns,
common.price_increment.precision,
common.size_increment.precision,
common.price_increment,
common.size_increment,
None,
definition.series_id,
common.max_quantity,
common.min_quantity,
common.max_notional,
common.min_notional,
Some(Price::from("1")),
Some(Price::from("0")),
margin_init,
margin_maint,
maker_fee,
taker_fee,
None,
Some(info),
ts_init,
ts_init,
)?;
Ok(InstrumentAny::BinaryOption(instrument))
}
fn parse_balance_field(value_str: &str, field_name: &str, ccy_str: &str) -> Option<Decimal> {
match Decimal::from_str(value_str) {
Ok(decimal) => Some(decimal),
Err(e) => {
log::warn!(
"Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
);
None
}
}
}
pub fn parse_account_state(
okx_account: &OKXAccount,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<AccountState> {
let mut balances = Vec::new();
for b in &okx_account.details {
let ccy_str = b.ccy.as_str().trim();
if ccy_str.is_empty() {
log::debug!("Skipping balance detail with empty currency code | raw_data={b:?}");
continue;
}
let currency = Currency::get_or_create_crypto_with_context(ccy_str, Some("balance detail"));
let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", ccy_str) else {
continue;
};
let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", ccy_str) else {
continue;
};
match AccountBalance::from_total_and_free(total, free, currency) {
Ok(balance) => balances.push(balance),
Err(e) => {
log::warn!("Skipping balance detail for {ccy_str} with invalid total/free: {e}");
}
}
}
if balances.is_empty() {
let zero_currency = Currency::USD();
let zero_money = Money::new(0.0, zero_currency);
let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
balances.push(zero_balance);
}
let mut margins = Vec::new();
if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
match (
Decimal::from_str(&okx_account.imr),
Decimal::from_str(&okx_account.mmr),
) {
(Ok(imr_dec), Ok(mmr_dec)) => {
if !imr_dec.is_zero() || !mmr_dec.is_zero() {
let margin_currency = Currency::USD();
let initial_margin = Money::from_decimal(imr_dec, margin_currency)
.unwrap_or_else(|e| {
log::error!("Failed to create initial margin: {e}");
Money::zero(margin_currency)
});
let maintenance_margin = Money::from_decimal(mmr_dec, margin_currency)
.unwrap_or_else(|e| {
log::error!("Failed to create maintenance margin: {e}");
Money::zero(margin_currency)
});
margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
}
}
(Err(e1), _) => {
log::warn!(
"Failed to parse initial margin requirement '{}': {}",
okx_account.imr,
e1
);
}
(_, Err(e2)) => {
log::warn!(
"Failed to parse maintenance margin requirement '{}': {}",
okx_account.mmr,
e2
);
}
}
}
let account_type = AccountType::Margin;
let is_reported = true;
let event_id = UUID4::new();
let ts_event = parse_millisecond_timestamp(okx_account.u_time);
Ok(AccountState::new(
account_id,
account_type,
balances,
margins,
is_reported,
event_id,
ts_event,
ts_init,
None,
))
}
pub fn nanos_to_datetime(value: Option<UnixNanos>) -> Option<chrono::DateTime<chrono::Utc>> {
value.map(|nanos| nanos.to_datetime_utc())
}
#[cfg(test)]
mod tests {
use nautilus_model::{identifiers::PositionId, instruments::Instrument};
use rstest::rstest;
use rust_decimal_macros::dec;
use super::*;
use crate::{
OKXPositionSide,
common::{enums::OKXMarginMode, testing::load_test_json},
http::{
client::OKXResponse,
models::{
OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
OKXPositionTier, OKXSpread, OKXTrade, OKXTransactionDetail,
},
},
};
#[rstest]
fn test_parse_fee_currency_with_zero_fee_empty_string() {
let result = parse_fee_currency("", Decimal::ZERO, || "test context".to_string());
assert_eq!(result, Currency::USDT());
}
#[rstest]
fn test_parse_fee_currency_with_zero_fee_valid_currency() {
let result = parse_fee_currency("BTC", Decimal::ZERO, || "test context".to_string());
assert_eq!(result, Currency::BTC());
}
#[rstest]
fn test_parse_fee_currency_with_valid_currency() {
let result = parse_fee_currency("BTC", dec!(0.001), || "test context".to_string());
assert_eq!(result, Currency::BTC());
}
#[rstest]
fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
let result = parse_fee_currency("", dec!(0.5), || "test context".to_string());
assert_eq!(result, Currency::USDT());
}
#[rstest]
fn test_parse_fee_currency_with_whitespace() {
let result = parse_fee_currency(" ETH ", dec!(0.002), || "test context".to_string());
assert_eq!(result, Currency::ETH());
}
#[rstest]
fn test_parse_fee_currency_with_unknown_code() {
let result = parse_fee_currency("NEWTOKEN", dec!(0.5), || "test context".to_string());
assert_eq!(result.code.as_str(), "NEWTOKEN");
assert_eq!(result.precision, 8);
}
#[rstest]
fn test_parse_balance_field_valid() {
let result = parse_balance_field("100.5", "test_field", "BTC");
assert_eq!(result, Some(dec!(100.5)));
}
#[rstest]
fn test_parse_balance_field_invalid_numeric() {
let result = parse_balance_field("not_a_number", "test_field", "BTC");
assert!(result.is_none());
}
#[rstest]
fn test_parse_balance_field_empty() {
let result = parse_balance_field("", "test_field", "BTC");
assert!(result.is_none());
}
#[rstest]
fn test_parse_trades() {
let json_data = load_test_json("http_get_trades.json");
let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 2);
let trade0 = &parsed.data[0];
assert_eq!(trade0.inst_id, "BTC-USDT");
assert_eq!(trade0.px, "102537.9");
assert_eq!(trade0.sz, "0.00013669");
assert_eq!(trade0.side, OKXSide::Sell);
assert_eq!(trade0.trade_id, "734864333");
assert_eq!(trade0.ts, 1747087163557);
assert_eq!(trade0.source.as_deref(), Some("1"));
let trade1 = &parsed.data[1];
assert_eq!(trade1.inst_id, "BTC-USDT");
assert_eq!(trade1.px, "102537.9");
assert_eq!(trade1.sz, "0.0000125");
assert_eq!(trade1.side, OKXSide::Buy);
assert_eq!(trade1.trade_id, "734864332");
assert_eq!(trade1.ts, 1747087161666);
assert_eq!(trade1.source.as_deref(), Some("0"));
}
#[rstest]
fn test_parse_candlesticks() {
let json_data = load_test_json("http_get_candlesticks.json");
let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 2);
let bar0 = &parsed.data[0];
assert_eq!(bar0.0, "1625097600000");
assert_eq!(bar0.1, "33528.6");
assert_eq!(bar0.2, "33870.0");
assert_eq!(bar0.3, "33528.6");
assert_eq!(bar0.4, "33783.9");
assert_eq!(bar0.5, "778.838");
let bar1 = &parsed.data[1];
assert_eq!(bar1.0, "1625097660000");
assert_eq!(bar1.1, "33783.9");
assert_eq!(bar1.2, "33783.9");
assert_eq!(bar1.3, "33782.1");
assert_eq!(bar1.4, "33782.1");
assert_eq!(bar1.5, "0.123");
}
#[rstest]
fn test_parse_candlesticks_full() {
let json_data = load_test_json("http_get_candlesticks_full.json");
let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 2);
let bar0 = &parsed.data[0];
assert_eq!(bar0.0, "1747094040000");
assert_eq!(bar0.1, "102806.1");
assert_eq!(bar0.2, "102820.4");
assert_eq!(bar0.3, "102806.1");
assert_eq!(bar0.4, "102820.4");
assert_eq!(bar0.5, "1040.37");
assert_eq!(bar0.6, "10.4037");
assert_eq!(bar0.7, "1069603.34883");
assert_eq!(bar0.8, "1");
let bar1 = &parsed.data[1];
assert_eq!(bar1.0, "1747093980000");
assert_eq!(bar1.5, "7164.04");
assert_eq!(bar1.6, "71.6404");
assert_eq!(bar1.7, "7364701.57952");
assert_eq!(bar1.8, "1");
}
#[rstest]
fn test_parse_mark_price() {
let json_data = load_test_json("http_get_mark_price.json");
let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let mark_price = &parsed.data[0];
assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
assert_eq!(mark_price.mark_px, "84660.1");
assert_eq!(mark_price.ts, 1744590349506);
}
#[rstest]
fn test_parse_index_price() {
let json_data = load_test_json("http_get_index_price.json");
let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let index_price = &parsed.data[0];
assert_eq!(index_price.inst_id, "BTC-USDT");
assert_eq!(index_price.idx_px, "103895");
assert_eq!(index_price.ts, 1746942707815);
}
#[rstest]
fn test_parse_account() {
let json_data = load_test_json("http_get_account_balance.json");
let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let account = &parsed.data[0];
assert_eq!(account.adj_eq, "");
assert_eq!(account.borrow_froz, "");
assert_eq!(account.imr, "");
assert_eq!(account.iso_eq, "5.4682385526666675");
assert_eq!(account.mgn_ratio, "");
assert_eq!(account.mmr, "");
assert_eq!(account.notional_usd, "");
assert_eq!(account.notional_usd_for_borrow, "");
assert_eq!(account.notional_usd_for_futures, "");
assert_eq!(account.notional_usd_for_option, "");
assert_eq!(account.notional_usd_for_swap, "");
assert_eq!(account.ord_froz, "");
assert_eq!(account.total_eq, "99.88870288820581");
assert_eq!(account.upl, "");
assert_eq!(account.u_time, 1744499648556);
assert_eq!(account.details.len(), 1);
let detail = &account.details[0];
assert_eq!(detail.ccy, "USDT");
assert_eq!(detail.avail_bal, "94.42612990333333");
assert_eq!(detail.avail_eq, "94.42612990333333");
assert_eq!(detail.cash_bal, "94.42612990333333");
assert_eq!(detail.dis_eq, "5.4682385526666675");
assert_eq!(detail.eq, "99.89469657000001");
assert_eq!(detail.eq_usd, "99.88870288820581");
assert_eq!(detail.fixed_bal, "0");
assert_eq!(detail.frozen_bal, "5.468566666666667");
assert_eq!(detail.imr, "0");
assert_eq!(detail.iso_eq, "5.468566666666667");
assert_eq!(detail.iso_upl, "-0.0273000000000002");
assert_eq!(detail.mmr, "0");
assert_eq!(detail.notional_lever, "0");
assert_eq!(detail.ord_frozen, "0");
assert_eq!(detail.reward_bal, "0");
assert_eq!(detail.smt_sync_eq, "0");
assert_eq!(detail.spot_copy_trading_eq, "0");
assert_eq!(detail.spot_iso_bal, "0");
assert_eq!(detail.stgy_eq, "0");
assert_eq!(detail.twap, "0");
assert_eq!(detail.upl, "-0.0273000000000002");
assert_eq!(detail.u_time, 1744498994783);
}
#[rstest]
fn test_parse_order_history() {
let json_data = load_test_json("http_get_orders_history.json");
let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let order = &parsed.data[0];
assert_eq!(order.ord_id, "2497956918703120384");
assert_eq!(order.fill_sz, "0.03");
assert_eq!(order.acc_fill_sz, "0.03");
assert_eq!(order.state, OKXOrderStatus::Filled);
assert!(order.fill_fee.is_none());
}
#[rstest]
fn test_parse_position() {
let json_data = load_test_json("http_get_positions.json");
let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let pos = &parsed.data[0];
assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
assert_eq!(pos.pos_side, OKXPositionSide::Long);
assert_eq!(pos.pos, "0.5");
assert_eq!(pos.base_bal, "0.5");
assert_eq!(pos.quote_bal, "5000");
assert_eq!(pos.u_time, 1622559930237);
}
#[rstest]
fn test_parse_position_history() {
let json_data = load_test_json("http_get_account_positions-history.json");
let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let hist = &parsed.data[0];
assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
assert_eq!(hist.pos_side, OKXPositionSide::Long);
assert_eq!(hist.lever, "3.0");
assert_eq!(hist.open_avg_px, "3226.93");
assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
assert!(!hist.c_time.is_empty());
assert!(hist.u_time > 0);
}
#[rstest]
fn test_parse_position_tiers() {
let json_data = load_test_json("http_get_position_tiers.json");
let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.code, "0");
assert_eq!(parsed.msg, "");
assert_eq!(parsed.data.len(), 1);
let tier = &parsed.data[0];
assert_eq!(tier.inst_id, "BTC-USDT");
assert_eq!(tier.tier, "1");
assert_eq!(tier.min_sz, "0");
assert_eq!(tier.max_sz, "50");
assert_eq!(tier.imr, "0.1");
assert_eq!(tier.mmr, "0.03");
}
#[rstest]
fn test_parse_account_field_name_compatibility() {
let json_new = load_test_json("http_balance_detail_new_fields.json");
let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
assert_eq!(detail_new.spot_in_use_amt, "30.0");
assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
let json_old = load_test_json("http_balance_detail_old_fields.json");
let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
assert_eq!(detail_old.spot_in_use_amt, "40.0");
assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
}
#[rstest]
fn test_parse_place_order_response() {
let json_data = load_test_json("http_place_order_response.json");
let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.ord_id, Some(Ustr::from("12345678901234567890")));
assert_eq!(parsed.cl_ord_id, Some(Ustr::from("client_order_123")));
assert_eq!(parsed.tag, Some(String::new()));
}
#[rstest]
fn test_parse_transaction_details() {
let json_data = load_test_json("http_transaction_detail.json");
let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
assert_eq!(parsed.trade_id, Ustr::from("123456789"));
assert_eq!(parsed.ord_id, Ustr::from("987654321"));
assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
assert_eq!(parsed.fill_px, "42000.5");
assert_eq!(parsed.fill_sz, "0.001");
assert_eq!(parsed.side, OKXSide::Buy);
assert_eq!(parsed.exec_type, OKXExecType::Taker);
assert_eq!(parsed.fee_ccy, "USDT");
assert_eq!(parsed.fee, Some("0.042".to_string()));
assert_eq!(parsed.ts, 1625097600000);
}
#[rstest]
fn test_parse_empty_fee_field() {
let json_data = load_test_json("http_transaction_detail_empty_fee.json");
let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
assert_eq!(parsed.fee, None);
}
#[rstest]
fn test_parse_optional_string_to_u64() {
use serde::Deserialize;
#[derive(Deserialize)]
struct TestStruct {
#[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
value: Option<u64>,
}
let json_cases = load_test_json("common_optional_string_to_u64.json");
let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
assert_eq!(cases[0].value, Some(12345));
assert_eq!(cases[1].value, None);
assert_eq!(cases[2].value, None);
}
#[rstest]
fn test_parse_error_handling() {
let invalid_price = "invalid-price";
let result = crate::common::parse::parse_price(invalid_price, 2);
result.unwrap_err();
let invalid_quantity = "invalid-quantity";
let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
result.unwrap_err();
}
#[rstest]
fn test_parse_spot_instrument() {
let json_data = load_test_json("http_get_instruments_spot.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst: &OKXInstrument = response
.data
.first()
.expect("Test data must have an instrument");
let instrument =
parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
assert_eq!(instrument.underlying(), None);
assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
assert_eq!(instrument.quote_currency(), Currency::USD());
assert_eq!(instrument.settlement_currency(), Currency::USD());
assert_eq!(instrument.price_precision(), 1);
assert_eq!(instrument.size_precision(), 8);
assert_eq!(instrument.price_increment(), Price::from("0.1"));
assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
assert_eq!(instrument.multiplier(), Quantity::from(1));
assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
assert_eq!(instrument.max_notional(), None);
assert_eq!(instrument.min_notional(), None);
assert_eq!(instrument.max_price(), None);
assert_eq!(instrument.min_price(), None);
}
#[rstest]
fn test_parse_spot_instrument_exposes_price_limit_percentages_as_info() {
let json_data = load_test_json("http_get_instruments_price_limit.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response
.data
.first()
.expect("Test data must have an instrument");
assert_eq!(okx_inst.init_px_lmt_pct, "0.05");
assert_eq!(okx_inst.float_px_lmt_pct, "0.03");
assert_eq!(okx_inst.max_px_lmt_pct, "0.15");
let instrument =
parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
let InstrumentAny::CurrencyPair(pair) = instrument else {
panic!("expected CurrencyPair");
};
let info = pair.info.expect("price-limit info must be set");
assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.05"));
assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.03"));
assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.15"));
assert_eq!(pair.max_price, None);
assert_eq!(pair.min_price, None);
}
#[rstest]
fn test_parse_margin_instrument() {
let json_data = load_test_json("http_get_instruments_margin.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst: &OKXInstrument = response
.data
.first()
.expect("Test data must have an instrument");
let instrument =
parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
assert_eq!(instrument.underlying(), None);
assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
assert_eq!(instrument.quote_currency(), Currency::USDT());
assert_eq!(instrument.settlement_currency(), Currency::USDT());
assert_eq!(instrument.price_precision(), 1);
assert_eq!(instrument.size_precision(), 8);
assert_eq!(instrument.price_increment(), Price::from("0.1"));
assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
assert_eq!(instrument.multiplier(), Quantity::from(1));
assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
assert_eq!(instrument.max_notional(), None);
assert_eq!(instrument.min_notional(), None);
assert_eq!(instrument.max_price(), None);
assert_eq!(instrument.min_price(), None);
}
#[rstest]
fn test_parse_spot_instrument_with_valid_ct_mult() {
let json_data = load_test_json("http_get_instruments_spot.json");
let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
if let Some(inst) = response.data.first_mut() {
inst.ct_mult = "0.01".to_string();
}
let okx_inst = response.data.first().unwrap();
let instrument =
parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
if let InstrumentAny::CurrencyPair(pair) = instrument {
assert_eq!(pair.multiplier, Quantity::from("0.01"));
} else {
panic!("Expected CurrencyPair instrument");
}
}
#[rstest]
fn test_parse_spot_instrument_with_invalid_ct_mult() {
let json_data = load_test_json("http_get_instruments_spot.json");
let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
if let Some(inst) = response.data.first_mut() {
inst.ct_mult = "invalid_number".to_string();
}
let okx_inst = response.data.first().unwrap();
let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to parse `ct_mult`")
);
}
#[rstest]
fn test_parse_spot_instrument_with_fees() {
let json_data = load_test_json("http_get_instruments_spot.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response.data.first().unwrap();
let maker_fee = Some(dec!(0.0008));
let taker_fee = Some(dec!(0.0010));
let instrument = parse_spot_instrument(
okx_inst,
None,
None,
maker_fee,
taker_fee,
UnixNanos::default(),
)
.unwrap();
if let InstrumentAny::CurrencyPair(pair) = instrument {
assert_eq!(pair.maker_fee, dec!(0.0008));
assert_eq!(pair.taker_fee, dec!(0.0010));
} else {
panic!("Expected CurrencyPair instrument");
}
}
#[rstest]
fn test_parse_instrument_any_passes_through_fees() {
let json_data = load_test_json("http_get_instruments_spot.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response.data.first().unwrap();
let maker_fee = Some(dec!(-0.00025)); let taker_fee = Some(dec!(0.00050));
let instrument = parse_instrument_any(
okx_inst,
None,
None,
maker_fee,
taker_fee,
UnixNanos::default(),
)
.unwrap()
.expect("Should parse spot instrument");
if let InstrumentAny::CurrencyPair(pair) = instrument {
assert_eq!(pair.maker_fee, dec!(-0.00025));
assert_eq!(pair.taker_fee, dec!(0.00050));
} else {
panic!("Expected CurrencyPair instrument");
}
}
#[rstest]
fn test_parse_swap_instrument() {
let json_data = load_test_json("http_get_instruments_swap.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst: &OKXInstrument = response
.data
.first()
.expect("Test data must have an instrument");
let instrument =
parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
assert_eq!(instrument.underlying(), None);
assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
assert_eq!(instrument.quote_currency(), Currency::USD());
assert_eq!(instrument.settlement_currency(), Currency::BTC());
assert!(instrument.is_inverse());
assert_eq!(instrument.price_precision(), 1);
assert_eq!(instrument.size_precision(), 0);
assert_eq!(instrument.price_increment(), Price::from("0.1"));
assert_eq!(instrument.size_increment(), Quantity::from(1));
assert_eq!(instrument.multiplier(), Quantity::from(100));
assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
assert_eq!(instrument.max_notional(), None);
assert_eq!(instrument.min_notional(), None);
assert_eq!(instrument.max_price(), None);
assert_eq!(instrument.min_price(), None);
}
#[rstest]
fn test_parse_swap_instrument_exposes_price_limit_percentages_as_info() {
let json_data = load_test_json("http_get_instruments_swap.json");
let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response
.data
.first_mut()
.expect("Test data must have an instrument");
okx_inst.init_px_lmt_pct = "0.05".to_string();
okx_inst.float_px_lmt_pct = "0.03".to_string();
okx_inst.max_px_lmt_pct = "0.15".to_string();
let instrument =
parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
panic!("expected CryptoPerpetual");
};
let info = perpetual.info.expect("price-limit info must be set");
assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.05"));
assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.03"));
assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.15"));
assert_eq!(perpetual.max_price, None);
assert_eq!(perpetual.min_price, None);
}
#[rstest]
fn test_deserialize_swap_instrument_with_rebase_state() {
let json_data = load_test_json("http_get_instruments_swap.json");
let mut value: serde_json::Value = serde_json::from_str(&json_data).unwrap();
value["data"][0]["state"] = serde_json::Value::String("rebase".to_string());
let response: OKXResponse<OKXInstrument> = serde_json::from_value(value).unwrap();
assert_eq!(response.data[0].inst_id, "BTC-USD-SWAP");
}
#[rstest]
fn test_parse_inverse_spread_instrument() {
let json_data = load_test_json("http_get_spreads.json");
let response: OKXResponse<OKXSpread> = serde_json::from_str(&json_data).unwrap();
let okx_spread = response.data.first().expect("Test data must have a spread");
let instrument =
parse_spread_instrument(okx_spread, None, None, None, None, UnixNanos::default())
.unwrap();
let InstrumentAny::CryptoFuturesSpread(spread) = instrument else {
panic!("Expected CryptoFuturesSpread");
};
let info = spread.info.as_ref().expect("spread info must be set");
let legs = info
.get("okx_spread_legs")
.and_then(serde_json::Value::as_array)
.expect("spread legs must be present");
assert_eq!(
spread.id,
InstrumentId::from("ETH-USD-SWAP_ETH-USD-231229.OKX")
);
assert_eq!(
spread.raw_symbol,
Symbol::from("ETH-USD-SWAP_ETH-USD-231229")
);
assert_eq!(spread.underlying, Currency::ETH());
assert_eq!(spread.quote_currency, Currency::USD());
assert_eq!(spread.settlement_currency, Currency::ETH());
assert!(spread.is_inverse);
assert_eq!(spread.strategy_type, Ustr::from("inverse"));
assert_eq!(spread.price_precision, 2);
assert_eq!(spread.size_precision, 0);
assert_eq!(spread.price_increment, Price::from("0.01"));
assert_eq!(spread.size_increment, Quantity::from("10"));
assert_eq!(spread.lot_size, Quantity::from("10"));
assert_eq!(spread.min_quantity, Some(Quantity::from("10")));
assert_eq!(spread.max_quantity, None);
assert_eq!(info.get_str("okx_sz_ccy"), Some("USD"));
assert_eq!(legs.len(), 2);
assert_eq!(legs[0]["inst_id"].as_str(), Some("ETH-USD-SWAP"));
assert_eq!(legs[0]["side"].as_str(), Some("sell"));
assert_eq!(legs[0]["ratio"].as_i64(), Some(-1));
assert_eq!(legs[1]["inst_id"].as_str(), Some("ETH-USD-231229"));
assert_eq!(legs[1]["side"].as_str(), Some("buy"));
assert_eq!(legs[1]["ratio"].as_i64(), Some(1));
}
#[rstest]
fn test_parse_linear_spread_instrument_without_expiry() {
let json_data = load_test_json("http_get_spreads.json");
let response: OKXResponse<OKXSpread> = serde_json::from_str(&json_data).unwrap();
let okx_spread = response
.data
.get(1)
.expect("Test data must have a linear spread");
let instrument =
parse_spread_instrument(okx_spread, None, None, None, None, UnixNanos::default())
.unwrap();
let InstrumentAny::CryptoFuturesSpread(spread) = instrument else {
panic!("Expected CryptoFuturesSpread");
};
assert_eq!(spread.id, InstrumentId::from("BTC-USDT_BTC-USDT-SWAP.OKX"));
assert_eq!(spread.underlying, Currency::BTC());
assert_eq!(spread.quote_currency, Currency::USDT());
assert_eq!(spread.settlement_currency, Currency::USDT());
assert!(!spread.is_inverse);
assert_eq!(spread.price_precision, 4);
assert_eq!(spread.size_precision, 3);
assert_eq!(spread.price_increment, Price::from("0.0001"));
assert_eq!(spread.size_increment, Quantity::from("0.001"));
assert_eq!(spread.expiration_ns, UnixNanos::default());
}
#[rstest]
fn test_parse_option_spread_instrument() {
let json_data = load_test_json("http_get_spreads.json");
let mut payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
let spread = payload["data"][0]
.as_object_mut()
.expect("spread payload must be an object");
spread.insert(
"sprdId".to_string(),
serde_json::Value::String(
"BTC-USD-260626-100000-C_BTC-USD-260626-110000-C".to_string(),
),
);
spread.insert(
"baseCcy".to_string(),
serde_json::Value::String("BTC".to_string()),
);
spread.insert(
"quoteCcy".to_string(),
serde_json::Value::String("USD".to_string()),
);
spread["legs"][0]["instId"] =
serde_json::Value::String("BTC-USD-260626-100000-C".to_string());
spread["legs"][1]["instId"] =
serde_json::Value::String("BTC-USD-260626-110000-C".to_string());
let response: OKXResponse<OKXSpread> = serde_json::from_value(payload).unwrap();
let instrument = parse_spread_instrument(
response.data.first().expect("Test data must have a spread"),
None,
None,
None,
None,
UnixNanos::default(),
)
.unwrap();
let InstrumentAny::CryptoOptionSpread(spread) = instrument else {
panic!("Expected CryptoOptionSpread");
};
let info = spread.info.as_ref().expect("spread info must be set");
let legs = info
.get("okx_spread_legs")
.and_then(serde_json::Value::as_array)
.expect("spread legs must be present");
assert_eq!(
spread.id,
InstrumentId::from("BTC-USD-260626-100000-C_BTC-USD-260626-110000-C.OKX")
);
assert_eq!(spread.underlying, Currency::BTC());
assert_eq!(spread.quote_currency, Currency::USD());
assert_eq!(legs[0]["inst_id"].as_str(), Some("BTC-USD-260626-100000-C"));
assert_eq!(legs[1]["inst_id"].as_str(), Some("BTC-USD-260626-110000-C"));
}
#[rstest]
#[case::empty_tick_size("tickSz", Some(""), "`tick_sz` is empty")]
#[case::empty_lot_size("lotSz", Some(""), "`lot_sz` is empty")]
#[case::invalid_min_size("minSz", Some("not-a-quantity"), "Failed to parse `min_sz`")]
#[case::missing_list_time("listTime", None, "`list_time` is required")]
fn test_parse_spread_instrument_rejects_invalid_fields(
#[case] field: &str,
#[case] value: Option<&str>,
#[case] expected: &str,
) {
let json_data = load_test_json("http_get_spreads.json");
let mut payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
let spread = payload["data"][0]
.as_object_mut()
.expect("spread payload must be an object");
if let Some(value) = value {
spread.insert(
field.to_string(),
serde_json::Value::String(value.to_string()),
);
} else {
spread.remove(field);
}
let response: OKXResponse<OKXSpread> = serde_json::from_value(payload).unwrap();
let result = parse_spread_instrument(
response.data.first().expect("Test data must have a spread"),
None,
None,
None,
None,
UnixNanos::default(),
);
let err = result.expect_err("invalid spread field must fail");
assert!(
err.to_string().contains(expected),
"expected error to contain {expected:?}, was {err}"
);
}
#[rstest]
fn test_parse_event_contract_instrument() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Events,
inst_id: Ustr::from("BTC-ABOVE-DAILY-260224-1600-65000"),
inst_id_code: Some(1000000001),
uly: Ustr::from(""),
inst_family: Ustr::from(""),
series_id: Some(Ustr::from("BTC-ABOVE-DAILY")),
inst_category: Some(OKXInstrumentCategory::Crypto),
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from("USDT"),
settle_ccy: Ustr::from("USDT"),
ct_val: String::new(),
ct_mult: String::new(),
ct_val_ccy: String::new(),
opt_type: crate::common::enums::OKXOptionType::None,
stk: String::new(),
list_time: Some(1769697132335),
exp_time: Some(1769700732335),
lever: String::new(),
tick_sz: "0.001".to_string(),
lot_sz: "1".to_string(),
min_sz: "1".to_string(),
ct_type: OKXContractType::None,
state: OKXInstrumentStatus::Settling,
rule_type: "normal".to_string(),
max_lmt_sz: "1000000".to_string(),
max_mkt_sz: "1000000".to_string(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let parsed = parse_event_contract_instrument(
&instrument,
None,
None,
Some(dec!(-0.0002)),
Some(dec!(-0.0005)),
UnixNanos::default(),
)
.unwrap();
let InstrumentAny::BinaryOption(binary) = parsed else {
panic!("Expected BinaryOption");
};
assert_eq!(
binary.id,
InstrumentId::from("BTC-ABOVE-DAILY-260224-1600-65000.OKX")
);
assert_eq!(binary.asset_class, AssetClass::Cryptocurrency);
assert_eq!(binary.currency, Currency::USDT());
assert_eq!(binary.price_increment, Price::from("0.001"));
assert_eq!(binary.size_increment, Quantity::from(1));
assert_eq!(binary.description, Some(Ustr::from("BTC-ABOVE-DAILY")));
assert_eq!(binary.maker_fee, dec!(-0.0002));
assert_eq!(binary.taker_fee, dec!(-0.0005));
}
#[rstest]
fn test_parse_linear_swap_instrument() {
let json_data = load_test_json("http_get_instruments_swap.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response
.data
.iter()
.find(|i| i.inst_id == "ETH-USDT-SWAP")
.expect("ETH-USDT-SWAP must be in test data");
let instrument =
parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
assert_eq!(instrument.quote_currency(), Currency::USDT());
assert_eq!(instrument.settlement_currency(), Currency::USDT());
assert!(!instrument.is_inverse());
assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
assert_eq!(instrument.price_precision(), 2);
assert_eq!(instrument.size_precision(), 2);
assert_eq!(instrument.price_increment(), Price::from("0.01"));
assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
}
#[rstest]
fn test_parse_inst_id_code_from_swap_instrument() {
let json_data = load_test_json("http_get_instruments_swap.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let btc_usd_swap = response
.data
.iter()
.find(|i| i.inst_id == "BTC-USD-SWAP")
.expect("BTC-USD-SWAP must be in test data");
assert_eq!(btc_usd_swap.inst_id_code, Some(10458));
let eth_usdt_swap = response
.data
.iter()
.find(|i| i.inst_id == "ETH-USDT-SWAP")
.expect("ETH-USDT-SWAP must be in test data");
assert_eq!(eth_usdt_swap.inst_id_code, Some(10461));
let btc_usdt_swap = response
.data
.iter()
.find(|i| i.inst_id == "BTC-USDT-SWAP")
.expect("BTC-USDT-SWAP must be in test data");
assert_eq!(btc_usdt_swap.inst_id_code, Some(10459));
}
#[rstest]
fn test_fee_field_selection_for_contract_types() {
let maker_crypto = "0.0002"; let taker_crypto = "0.0005"; let maker_usdt = "0.0008"; let taker_usdt = "0.0010";
let is_usdt_margined = true;
let (maker_str, taker_str) = if is_usdt_margined {
(maker_usdt, taker_usdt)
} else {
(maker_crypto, taker_crypto)
};
assert_eq!(maker_str, "0.0008");
assert_eq!(taker_str, "0.0010");
let maker_fee = Decimal::from_str(maker_str).unwrap();
let taker_fee = Decimal::from_str(taker_str).unwrap();
assert_eq!(maker_fee, dec!(0.0008));
assert_eq!(taker_fee, dec!(0.0010));
let is_usdt_margined = false;
let (maker_str, taker_str) = if is_usdt_margined {
(maker_usdt, taker_usdt)
} else {
(maker_crypto, taker_crypto)
};
assert_eq!(maker_str, "0.0002");
assert_eq!(taker_str, "0.0005");
let maker_fee = Decimal::from_str(maker_str).unwrap();
let taker_fee = Decimal::from_str(taker_str).unwrap();
assert_eq!(maker_fee, dec!(0.0002));
assert_eq!(taker_fee, dec!(0.0005));
}
#[rstest]
fn test_parse_futures_instrument() {
let json_data = load_test_json("http_get_instruments_futures.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst: &OKXInstrument = response
.data
.first()
.expect("Test data must have an instrument");
let instrument =
parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
.unwrap();
assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
assert_eq!(instrument.quote_currency(), Currency::USD());
assert_eq!(instrument.settlement_currency(), Currency::BTC());
assert!(instrument.is_inverse());
assert_eq!(instrument.price_precision(), 1);
assert_eq!(instrument.size_precision(), 0);
assert_eq!(instrument.price_increment(), Price::from("0.1"));
assert_eq!(instrument.size_increment(), Quantity::from(1));
assert_eq!(instrument.multiplier(), Quantity::from(100));
assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
let InstrumentAny::CryptoFuture(crypto_future) = instrument else {
panic!("expected CryptoFuture, was {instrument:?}");
};
let info = crypto_future.info.expect("info populated for FUTURES");
assert_eq!(info.get_str("rule_type"), Some("normal"));
}
#[rstest]
fn test_parse_futures_instrument_merges_price_limit_percentages_with_rule_type() {
let json_data = load_test_json("http_get_instruments_futures.json");
let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst = response
.data
.first_mut()
.expect("Test data must have an instrument");
okx_inst.init_px_lmt_pct = "0.04".to_string();
okx_inst.float_px_lmt_pct = "0.02".to_string();
okx_inst.max_px_lmt_pct = "0.12".to_string();
let instrument =
parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
.unwrap();
let InstrumentAny::CryptoFuture(crypto_future) = instrument else {
panic!("expected CryptoFuture");
};
let info = crypto_future.info.expect("price-limit info must be set");
assert_eq!(info.get_str("rule_type"), Some("normal"));
assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.04"));
assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.02"));
assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.12"));
assert_eq!(crypto_future.max_price, None);
assert_eq!(crypto_future.min_price, None);
}
#[rstest]
fn test_parse_futures_instrument_xperp_carries_rule_type() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Futures,
inst_id: Ustr::from("BTC-USDT-250328"),
uly: Ustr::from("BTC-USDT"),
inst_family: Ustr::from("BTC-USDT"),
series_id: None,
inst_category: None,
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from("USDT"),
settle_ccy: Ustr::from("USDT"),
ct_val: "1".to_string(),
ct_mult: "1".to_string(),
ct_val_ccy: "USDT".to_string(),
opt_type: crate::common::enums::OKXOptionType::None,
stk: String::new(),
list_time: Some(1_700_000_000_000),
exp_time: Some(1_743_004_800_000),
lever: "10".to_string(),
tick_sz: "0.1".to_string(),
lot_sz: "1".to_string(),
min_sz: "1".to_string(),
ct_type: OKXContractType::Linear,
state: crate::common::enums::OKXInstrumentStatus::Live,
rule_type: "xperp".to_string(),
max_lmt_sz: String::new(),
max_mkt_sz: String::new(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
inst_id_code: None,
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let parsed =
parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default())
.expect("parses synthetic X-Perp instrument");
let InstrumentAny::CryptoFuture(crypto_future) = parsed else {
panic!("expected CryptoFuture for X-Perp");
};
let info = crypto_future.info.expect("info populated for X-Perp");
assert_eq!(info.get_str("rule_type"), Some("xperp"));
assert!(is_xperp_rule_type("xperp"));
assert!(is_xperp_rule_type("XPERP"));
assert!(!is_xperp_rule_type("normal"));
}
#[rstest]
fn test_parse_option_instrument() {
let json_data = load_test_json("http_get_instruments_option.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
let okx_inst: &OKXInstrument = response
.data
.first()
.expect("Test data must have an instrument");
let instrument =
parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
.unwrap();
assert_eq!(
instrument.id(),
InstrumentId::from("BTC-USD-241217-92000-C.OKX")
);
assert_eq!(
instrument.raw_symbol(),
Symbol::from("BTC-USD-241217-92000-C")
);
assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
assert_eq!(instrument.quote_currency(), Currency::USD());
assert_eq!(instrument.settlement_currency(), Currency::BTC());
assert!(instrument.is_inverse());
assert_eq!(instrument.price_precision(), 4);
assert_eq!(instrument.size_precision(), 0);
assert_eq!(instrument.price_increment(), Price::from("0.0001"));
assert_eq!(instrument.size_increment(), Quantity::from(1));
assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
assert_eq!(instrument.max_notional(), None);
assert_eq!(instrument.min_notional(), None);
assert_eq!(instrument.max_price(), None);
assert_eq!(instrument.min_price(), None);
}
#[rstest]
fn test_parse_account_state() {
let json_data = load_test_json("http_get_account_balance.json");
let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
let okx_account = response
.data
.first()
.expect("Test data must have an account");
let account_id = AccountId::new("OKX-001");
let account_state =
parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
assert_eq!(account_state.account_id, account_id);
assert_eq!(account_state.account_type, AccountType::Margin);
assert_eq!(account_state.balances.len(), 1);
assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
let usdt_balance = &account_state.balances[0];
assert_eq!(
usdt_balance.total,
Money::new(94.42612990333333, Currency::USDT())
);
assert_eq!(
usdt_balance.free,
Money::new(94.42612990333333, Currency::USDT())
);
assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
}
#[rstest]
fn test_parse_account_state_with_margins() {
let account_json = r#"{
"adjEq": "10000.0",
"borrowFroz": "0",
"details": [{
"accAvgPx": "",
"availBal": "8000.0",
"availEq": "8000.0",
"borrowFroz": "0",
"cashBal": "10000.0",
"ccy": "USDT",
"clSpotInUseAmt": "0",
"coinUsdPrice": "1.0",
"colBorrAutoConversion": "0",
"collateralEnabled": false,
"collateralRestrict": false,
"crossLiab": "0",
"disEq": "10000.0",
"eq": "10000.0",
"eqUsd": "10000.0",
"fixedBal": "0",
"frozenBal": "2000.0",
"imr": "0",
"interest": "0",
"isoEq": "0",
"isoLiab": "0",
"isoUpl": "0",
"liab": "0",
"maxLoan": "0",
"mgnRatio": "0",
"maxSpotInUseAmt": "0",
"mmr": "0",
"notionalLever": "0",
"openAvgPx": "",
"ordFrozen": "2000.0",
"rewardBal": "0",
"smtSyncEq": "0",
"spotBal": "0",
"spotCopyTradingEq": "0",
"spotInUseAmt": "0",
"spotIsoBal": "0",
"spotUpl": "0",
"spotUplRatio": "0",
"stgyEq": "0",
"totalPnl": "0",
"totalPnlRatio": "0",
"twap": "0",
"uTime": "1704067200000",
"upl": "0",
"uplLiab": "0"
}],
"imr": "500.25",
"isoEq": "0",
"mgnRatio": "20.5",
"mmr": "250.75",
"notionalUsd": "5000.0",
"notionalUsdForBorrow": "0",
"notionalUsdForFutures": "0",
"notionalUsdForOption": "0",
"notionalUsdForSwap": "5000.0",
"ordFroz": "2000.0",
"totalEq": "10000.0",
"uTime": "1704067200000",
"upl": "0"
}"#;
let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
let account_id = AccountId::new("OKX-001");
let account_state =
parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
assert_eq!(account_state.account_id, account_id);
assert_eq!(account_state.account_type, AccountType::Margin);
assert_eq!(account_state.balances.len(), 1);
assert_eq!(account_state.margins.len(), 1);
let margin = &account_state.margins[0];
assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
assert_eq!(margin.currency, Currency::USD());
assert!(margin.instrument_id.is_none());
let usdt_balance = &account_state.balances[0];
assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
}
#[rstest]
fn test_parse_account_state_empty_margins() {
let account_json = r#"{
"adjEq": "",
"borrowFroz": "",
"details": [{
"accAvgPx": "",
"availBal": "1000.0",
"availEq": "1000.0",
"borrowFroz": "0",
"cashBal": "1000.0",
"ccy": "BTC",
"clSpotInUseAmt": "0",
"coinUsdPrice": "50000.0",
"colBorrAutoConversion": "0",
"collateralEnabled": false,
"collateralRestrict": false,
"crossLiab": "0",
"disEq": "50000.0",
"eq": "1000.0",
"eqUsd": "50000.0",
"fixedBal": "0",
"frozenBal": "0",
"imr": "0",
"interest": "0",
"isoEq": "0",
"isoLiab": "0",
"isoUpl": "0",
"liab": "0",
"maxLoan": "0",
"mgnRatio": "0",
"maxSpotInUseAmt": "0",
"mmr": "0",
"notionalLever": "0",
"openAvgPx": "",
"ordFrozen": "0",
"rewardBal": "0",
"smtSyncEq": "0",
"spotBal": "0",
"spotCopyTradingEq": "0",
"spotInUseAmt": "0",
"spotIsoBal": "0",
"spotUpl": "0",
"spotUplRatio": "0",
"stgyEq": "0",
"totalPnl": "0",
"totalPnlRatio": "0",
"twap": "0",
"uTime": "1704067200000",
"upl": "0",
"uplLiab": "0"
}],
"imr": "",
"isoEq": "0",
"mgnRatio": "",
"mmr": "",
"notionalUsd": "",
"notionalUsdForBorrow": "",
"notionalUsdForFutures": "",
"notionalUsdForOption": "",
"notionalUsdForSwap": "",
"ordFroz": "",
"totalEq": "50000.0",
"uTime": "1704067200000",
"upl": "0"
}"#;
let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
let account_id = AccountId::new("OKX-SPOT");
let account_state =
parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
assert_eq!(account_state.margins.len(), 0);
assert_eq!(account_state.balances.len(), 1);
let btc_balance = &account_state.balances[0];
assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
}
#[rstest]
fn test_parse_account_state_empty_balance_account() {
let account_json = r#"{
"adjEq": "",
"borrowFroz": "",
"details": [],
"imr": "",
"isoEq": "0",
"mgnRatio": "",
"mmr": "",
"notionalUsd": "",
"notionalUsdForBorrow": "",
"notionalUsdForFutures": "",
"notionalUsdForOption": "",
"notionalUsdForSwap": "",
"ordFroz": "",
"totalEq": "0",
"uTime": "1774795570586",
"upl": ""
}"#;
let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
let account_id = AccountId::new("OKX-001");
let account_state =
parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
assert_eq!(account_state.account_id, account_id);
assert_eq!(account_state.account_type, AccountType::Margin);
assert_eq!(account_state.margins.len(), 0);
assert_eq!(account_state.balances.len(), 1);
let balance = &account_state.balances[0];
assert_eq!(balance.total, Money::new(0.0, Currency::USD()));
assert_eq!(balance.free, Money::new(0.0, Currency::USD()));
assert_eq!(balance.locked, Money::new(0.0, Currency::USD()));
}
#[rstest]
fn test_parse_order_status_report() {
let json_data = load_test_json("http_get_orders_history.json");
let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
let okx_order = response
.data
.first()
.expect("Test data must have an order")
.clone();
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let order_report = parse_order_status_report(
&okx_order,
account_id,
instrument_id,
2,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(order_report.account_id, account_id);
assert_eq!(order_report.instrument_id, instrument_id);
assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
assert_eq!(order_report.order_side, OrderSide::Buy);
assert_eq!(order_report.order_type, OrderType::Market);
assert_eq!(order_report.order_status, OrderStatus::Filled);
}
#[rstest]
fn test_parse_position_status_report() {
let json_data = load_test_json("http_get_positions.json");
let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
let okx_position = response
.data
.first()
.expect("Test data must have a position")
.clone();
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let position_report = parse_position_status_report(
&okx_position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(position_report.account_id, account_id);
assert_eq!(position_report.instrument_id, instrument_id);
}
#[rstest]
fn test_parse_trade_tick() {
let json_data = load_test_json("http_get_trades.json");
let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
let okx_trade = response.data.first().expect("Test data must have a trade");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let trade_tick =
parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
assert_eq!(trade_tick.instrument_id, instrument_id);
assert_eq!(trade_tick.price, Price::from("102537.90"));
assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
}
#[rstest]
fn test_parse_mark_price_update() {
let json_data = load_test_json("http_get_mark_price.json");
let response: OKXResponse<crate::http::models::OKXMarkPrice> =
serde_json::from_str(&json_data).unwrap();
let okx_mark_price = response
.data
.first()
.expect("Test data must have a mark price");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let mark_price_update =
parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
.unwrap();
assert_eq!(mark_price_update.instrument_id, instrument_id);
assert_eq!(mark_price_update.value, Price::from("84660.10"));
assert_eq!(
mark_price_update.ts_event,
UnixNanos::from(1744590349506000000)
);
}
#[rstest]
fn test_parse_index_price_update() {
let json_data = load_test_json("http_get_index_price.json");
let response: OKXResponse<crate::http::models::OKXIndexTicker> =
serde_json::from_str(&json_data).unwrap();
let okx_index_ticker = response
.data
.first()
.expect("Test data must have an index ticker");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let index_price_update =
parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
.unwrap();
assert_eq!(index_price_update.instrument_id, instrument_id);
assert_eq!(index_price_update.value, Price::from("103895.00"));
assert_eq!(
index_price_update.ts_event,
UnixNanos::from(1746942707815000000)
);
}
#[rstest]
fn test_parse_candlestick() {
let json_data = load_test_json("http_get_candlesticks.json");
let response: OKXResponse<crate::http::models::OKXCandlestick> =
serde_json::from_str(&json_data).unwrap();
let okx_candlestick = response
.data
.first()
.expect("Test data must have a candlestick");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let bar_type = BarType::new(
instrument_id,
BAR_SPEC_1_DAY_LAST,
AggregationSource::External,
);
let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
assert_eq!(bar.bar_type, bar_type);
assert_eq!(bar.open, Price::from("33528.60"));
assert_eq!(bar.high, Price::from("33870.00"));
assert_eq!(bar.low, Price::from("33528.60"));
assert_eq!(bar.close, Price::from("33783.90"));
assert_eq!(bar.volume, Quantity::from("778.83800000"));
assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
}
#[rstest]
fn test_parse_millisecond_timestamp() {
let timestamp_ms = 1625097600000u64;
let result = parse_millisecond_timestamp(timestamp_ms);
assert_eq!(result, UnixNanos::from(1625097600000000000));
}
#[rstest]
fn test_parse_rfc3339_timestamp() {
let timestamp_str = "2021-07-01T00:00:00.000Z";
let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
assert_eq!(result, UnixNanos::from(1625097600000000000));
let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
let invalid_timestamp = "invalid-timestamp";
parse_rfc3339_timestamp(invalid_timestamp).unwrap_err();
}
#[rstest]
fn test_parse_price() {
let price_str = "42219.5";
let precision = 2;
let result = parse_price(price_str, precision).unwrap();
assert_eq!(result, Price::from("42219.50"));
let invalid_price = "invalid-price";
parse_price(invalid_price, precision).unwrap_err();
}
#[rstest]
fn test_parse_quantity() {
let quantity_str = "0.12345678";
let precision = 8;
let result = parse_quantity(quantity_str, precision).unwrap();
assert_eq!(result, Quantity::from("0.12345678"));
let invalid_quantity = "invalid-quantity";
parse_quantity(invalid_quantity, precision).unwrap_err();
}
#[rstest]
fn test_parse_aggressor_side() {
assert_eq!(
parse_aggressor_side(&Some(OKXSide::Buy)),
AggressorSide::Buyer
);
assert_eq!(
parse_aggressor_side(&Some(OKXSide::Sell)),
AggressorSide::Seller
);
assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
}
#[rstest]
fn test_parse_execution_type() {
assert_eq!(
parse_execution_type(&Some(OKXExecType::Maker)),
LiquiditySide::Maker
);
assert_eq!(
parse_execution_type(&Some(OKXExecType::Taker)),
LiquiditySide::Taker
);
assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
}
#[rstest]
fn test_parse_position_side() {
assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
assert_eq!(parse_position_side(None), PositionSide::Flat);
}
#[rstest]
fn test_parse_client_order_id() {
let valid_id = "client_order_123";
let result = parse_client_order_id(valid_id);
assert_eq!(result, Some(ClientOrderId::new(valid_id)));
let empty_id = "";
let result_empty = parse_client_order_id(empty_id);
assert_eq!(result_empty, None);
}
#[rstest]
fn test_deserialize_empty_string_as_none() {
let json_with_empty = r#""""#;
let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
let processed = result.filter(|s| !s.is_empty());
assert_eq!(processed, None);
let json_with_value = r#""test_value""#;
let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
let processed = result.filter(|s| !s.is_empty());
assert_eq!(processed, Some("test_value".to_string()));
}
#[rstest]
fn test_deserialize_string_to_u64() {
use serde::Deserialize;
#[derive(Deserialize)]
struct TestStruct {
#[serde(deserialize_with = "deserialize_string_to_u64")]
value: u64,
}
let json_value = r#"{"value": "12345"}"#;
let result: TestStruct = serde_json::from_str(json_value).unwrap();
assert_eq!(result.value, 12345);
let json_empty = r#"{"value": ""}"#;
let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
assert_eq!(result_empty.value, 0);
}
#[rstest]
fn test_fill_report_parsing() {
let transaction_detail = crate::http::models::OKXTransactionDetail {
inst_type: OKXInstrumentType::Spot,
inst_id: Ustr::from("BTC-USDT"),
trade_id: Ustr::from("12345"),
ord_id: Ustr::from("67890"),
cl_ord_id: Ustr::from("client_123"),
bill_id: Ustr::from("bill_456"),
fill_px: "42219.5".to_string(),
fill_sz: "0.001".to_string(),
side: OKXSide::Buy,
exec_type: OKXExecType::Taker,
fee_ccy: "USDT".to_string(),
fee: Some("0.042".to_string()),
ts: 1625097600000,
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let fill_report = parse_fill_report(
&transaction_detail,
account_id,
instrument_id,
2,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(fill_report.account_id, account_id);
assert_eq!(fill_report.instrument_id, instrument_id);
assert_eq!(fill_report.trade_id, TradeId::new("12345"));
assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
assert_eq!(fill_report.order_side, OrderSide::Buy);
assert_eq!(fill_report.last_px, Price::from("42219.50"));
assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
}
#[rstest]
fn test_bar_type_identity_preserved_through_parse() {
use std::str::FromStr;
use crate::http::models::OKXCandlestick;
let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
let raw_candlestick = OKXCandlestick(
"1721807460000".to_string(), "3177.9".to_string(), "3177.9".to_string(), "3177.7".to_string(), "3177.8".to_string(), "18.603".to_string(), "59054.8231".to_string(), "18.603".to_string(), "1".to_string(), );
let bar =
parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
assert_eq!(
bar.bar_type, bar_type,
"BarType must be preserved exactly through parsing"
);
}
#[rstest]
fn test_deserialize_vip_level_all_formats() {
use serde::Deserialize;
use serde_json;
#[derive(Deserialize)]
struct TestFeeRate {
#[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
level: OKXVipLevel,
}
let json = r#"{"level":"VIP4"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip4);
let json = r#"{"level":"VIP5"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip5);
let json = r#"{"level":"Lv1"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip1);
let json = r#"{"level":"Lv0"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip0);
let json = r#"{"level":"Lv9"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip9);
}
#[rstest]
fn test_deserialize_vip_level_empty_string() {
use serde::Deserialize;
use serde_json;
#[derive(Deserialize)]
struct TestFeeRate {
#[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
level: OKXVipLevel,
}
let json = r#"{"level":""}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip0);
}
#[rstest]
fn test_deserialize_vip_level_without_prefix() {
use serde::Deserialize;
use serde_json;
#[derive(Deserialize)]
struct TestFeeRate {
#[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
level: OKXVipLevel,
}
let json = r#"{"level":"5"}"#;
let result: TestFeeRate = serde_json::from_str(json).unwrap();
assert_eq!(result.level, OKXVipLevel::Vip5);
}
#[rstest]
fn test_parse_position_status_report_net_mode_long() {
let position = OKXPosition {
inst_id: Ustr::from("BTC-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("12345")),
pos_side: OKXPositionSide::Net, pos: "1.5".to_string(), base_bal: "1.5".to_string(),
ccy: "BTC".to_string(),
fee: "0.01".to_string(),
lever: "10.0".to_string(),
last: "50000".to_string(),
mark_px: "50000".to_string(),
liq_px: "45000".to_string(),
mmr: "0.1".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("111"),
notional_usd: "75000".to_string(),
avg_px: "50000".to_string(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "0.5".to_string(),
mgn_ratio: "0.01".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "1.5".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "BTC".to_string(),
quote_bal: "75000".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: "BTC".to_string(),
usd_px: "50000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Long.as_specified());
assert_eq!(report.quantity, Quantity::from("1.5"));
assert_eq!(report.venue_position_id, None);
}
#[rstest]
fn test_parse_position_status_report_net_mode_short() {
let position = OKXPosition {
inst_id: Ustr::from("BTC-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
mgn_mode: OKXMarginMode::Isolated,
pos_id: Some(Ustr::from("67890")),
pos_side: OKXPositionSide::Net, pos: "-2.3".to_string(), base_bal: "2.3".to_string(),
ccy: "BTC".to_string(),
fee: "0.02".to_string(),
lever: "5.0".to_string(),
last: "50000".to_string(),
mark_px: "50000".to_string(),
liq_px: "55000".to_string(),
mmr: "0.2".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("222"),
notional_usd: "115000".to_string(),
avg_px: "50000".to_string(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "1.0".to_string(),
mgn_ratio: "0.02".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "2.3".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "BTC".to_string(),
quote_bal: "115000".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: "BTC".to_string(),
usd_px: "50000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity, Quantity::from("2.3")); assert_eq!(report.venue_position_id, None);
}
#[rstest]
fn test_parse_position_status_report_net_mode_flat() {
let position = OKXPosition {
inst_id: Ustr::from("ETH-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("99999")),
pos_side: OKXPositionSide::Net, pos: "0".to_string(), base_bal: "0".to_string(),
ccy: "ETH".to_string(),
fee: "0".to_string(),
lever: "10.0".to_string(),
last: "3000".to_string(),
mark_px: "3000".to_string(),
liq_px: "0".to_string(),
mmr: "0".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("333"),
notional_usd: "0".to_string(),
avg_px: String::new(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "0".to_string(),
mgn_ratio: "0".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "0".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "ETH".to_string(),
quote_bal: "0".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: "ETH".to_string(),
usd_px: "3000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Flat.as_specified());
assert_eq!(report.quantity, Quantity::from("0"));
assert_eq!(report.venue_position_id, None);
}
#[rstest]
fn test_parse_position_status_report_long_short_mode_long() {
let position = OKXPosition {
inst_id: Ustr::from("BTC-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("11111")),
pos_side: OKXPositionSide::Long, pos: "3.2".to_string(), base_bal: "3.2".to_string(),
ccy: "BTC".to_string(),
fee: "0.01".to_string(),
lever: "10.0".to_string(),
last: "50000".to_string(),
mark_px: "50000".to_string(),
liq_px: "45000".to_string(),
mmr: "0.1".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("444"),
notional_usd: "160000".to_string(),
avg_px: "50000".to_string(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "1.6".to_string(),
mgn_ratio: "0.01".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "3.2".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "BTC".to_string(),
quote_bal: "160000".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: "BTC".to_string(),
usd_px: "50000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Long.as_specified());
assert_eq!(report.quantity, Quantity::from("3.2"));
assert_eq!(
report.venue_position_id,
Some(PositionId::new("11111-LONG"))
);
}
#[rstest]
fn test_parse_position_status_report_long_short_mode_short() {
let position = OKXPosition {
inst_id: Ustr::from("BTC-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("22222")),
pos_side: OKXPositionSide::Short, pos: "1.8".to_string(), base_bal: "1.8".to_string(),
ccy: "BTC".to_string(),
fee: "0.02".to_string(),
lever: "10.0".to_string(),
last: "50000".to_string(),
mark_px: "50000".to_string(),
liq_px: "55000".to_string(),
mmr: "0.2".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("555"),
notional_usd: "90000".to_string(),
avg_px: "50000".to_string(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "0.9".to_string(),
mgn_ratio: "0.02".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "1.8".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "BTC".to_string(),
quote_bal: "90000".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: "BTC".to_string(),
usd_px: "50000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
8,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity, Quantity::from("1.8"));
assert_eq!(
report.venue_position_id,
Some(PositionId::new("22222-SHORT"))
);
}
#[rstest]
fn test_parse_position_status_report_margin_long() {
let position = OKXPosition {
inst_id: Ustr::from("ETH-USDT"),
inst_type: OKXInstrumentType::Margin,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("margin-long-1")),
pos_side: OKXPositionSide::Net,
pos: "1.5".to_string(), base_bal: "1.5".to_string(),
ccy: "ETH".to_string(),
fee: "0".to_string(),
lever: "3".to_string(),
last: "4000".to_string(),
mark_px: "4000".to_string(),
liq_px: "3500".to_string(),
mmr: "0.1".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("trade1"),
notional_usd: "6000".to_string(),
avg_px: "3800".to_string(), upl: "300".to_string(),
upl_ratio: "0.05".to_string(),
u_time: 1622559930237,
margin: "2000".to_string(),
mgn_ratio: "0.33".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "300".to_string(),
upl_ratio_last_px: "0.05".to_string(),
avail_pos: "1.5".to_string(),
be_px: "3800".to_string(),
funding_fee: "0".to_string(),
idx_px: "4000".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "300".to_string(),
pos_ccy: "ETH".to_string(), quote_bal: "0".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: String::new(),
usd_px: "4000".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("ETH-USDT.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
4,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Long.as_specified());
assert_eq!(report.quantity, Quantity::from("1.5")); assert_eq!(report.venue_position_id, None); }
#[rstest]
fn test_parse_position_status_report_margin_short() {
let position = OKXPosition {
inst_id: Ustr::from("ETH-USDT"),
inst_type: OKXInstrumentType::Margin,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("margin-short-1")),
pos_side: OKXPositionSide::Net,
pos: "244.56".to_string(), base_bal: "0".to_string(),
ccy: "USDT".to_string(),
fee: "0".to_string(),
lever: "3".to_string(),
last: "4092".to_string(),
mark_px: "4092".to_string(),
liq_px: "4500".to_string(),
mmr: "0.1".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("trade2"),
notional_usd: "244.56".to_string(),
avg_px: "4092".to_string(), upl: "-10".to_string(),
upl_ratio: "-0.04".to_string(),
u_time: 1622559930237,
margin: "100".to_string(),
mgn_ratio: "0.4".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "-10".to_string(),
upl_ratio_last_px: "-0.04".to_string(),
avail_pos: "244.56".to_string(),
be_px: "4092".to_string(),
funding_fee: "0".to_string(),
idx_px: "4092".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "-10".to_string(),
pos_ccy: "USDT".to_string(), quote_bal: "244.56".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: String::new(),
usd_px: "4092".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("ETH-USDT.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
4,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity.to_string(), "0.0598");
assert_eq!(report.venue_position_id, None); }
#[rstest]
fn test_parse_position_status_report_margin_short_rounds_to_size_precision() {
let position = OKXPosition {
inst_id: Ustr::from("ETH-USDT"),
inst_type: OKXInstrumentType::Margin,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("margin-short-2")),
pos_side: OKXPositionSide::Net,
pos: "100.00".to_string(),
base_bal: "0".to_string(),
ccy: "USDT".to_string(),
fee: "0".to_string(),
lever: "3".to_string(),
last: "3333.33".to_string(),
mark_px: "3333.33".to_string(),
liq_px: "3500".to_string(),
mmr: "0.1".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from("trade-round"),
notional_usd: "100.00".to_string(),
avg_px: "3333.33".to_string(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "50".to_string(),
mgn_ratio: "0.5".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "100.00".to_string(),
be_px: "3333.33".to_string(),
funding_fee: "0".to_string(),
idx_px: "3333.33".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: "USDT".to_string(),
quote_bal: "100.00".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: String::new(),
usd_px: "3333.33".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let report = parse_position_status_report(
&position,
AccountId::new("OKX-001"),
InstrumentId::from("ETH-USDT.OKX"),
4, UnixNanos::default(),
)
.unwrap();
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity.to_string(), "0.0300");
}
#[rstest]
fn test_parse_rfc3339_timestamp_rejects_pre_epoch() {
let result = parse_rfc3339_timestamp("1960-01-01T00:00:00Z");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Negative nanosecond timestamp")
);
}
#[rstest]
fn test_parse_position_status_report_margin_flat() {
let position = OKXPosition {
inst_id: Ustr::from("ETH-USDT"),
inst_type: OKXInstrumentType::Margin,
mgn_mode: OKXMarginMode::Cross,
pos_id: Some(Ustr::from("margin-flat-1")),
pos_side: OKXPositionSide::Net,
pos: "0".to_string(),
base_bal: "0".to_string(),
ccy: "ETH".to_string(),
fee: "0".to_string(),
lever: "0".to_string(),
last: "4000".to_string(),
mark_px: "4000".to_string(),
liq_px: "0".to_string(),
mmr: "0".to_string(),
interest: "0".to_string(),
trade_id: Ustr::from(""),
notional_usd: "0".to_string(),
avg_px: String::new(),
upl: "0".to_string(),
upl_ratio: "0".to_string(),
u_time: 1622559930237,
margin: "0".to_string(),
mgn_ratio: "0".to_string(),
adl: "0".to_string(),
c_time: "1622559930237".to_string(),
realized_pnl: "0".to_string(),
upl_last_px: "0".to_string(),
upl_ratio_last_px: "0".to_string(),
avail_pos: "0".to_string(),
be_px: "0".to_string(),
funding_fee: "0".to_string(),
idx_px: "0".to_string(),
liq_penalty: "0".to_string(),
opt_val: "0".to_string(),
pending_close_ord_liab_val: "0".to_string(),
pnl: "0".to_string(),
pos_ccy: String::new(), quote_bal: "0".to_string(),
quote_borrowed: "0".to_string(),
quote_interest: "0".to_string(),
spot_in_use_amt: "0".to_string(),
spot_in_use_ccy: String::new(),
usd_px: "0".to_string(),
delta_bs: String::new(),
gamma_bs: String::new(),
theta_bs: String::new(),
vega_bs: String::new(),
};
let account_id = AccountId::new("OKX-001");
let instrument_id = InstrumentId::from("ETH-USDT.OKX");
let report = parse_position_status_report(
&position,
account_id,
instrument_id,
4,
UnixNanos::default(),
)
.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.position_side, PositionSide::Flat.as_specified());
assert_eq!(report.quantity, Quantity::from("0"));
assert_eq!(report.venue_position_id, None); }
#[rstest]
fn test_parse_swap_instrument_empty_underlying_returns_error() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Swap,
inst_id: Ustr::from("ETH-USD_UM-SWAP"),
uly: Ustr::from(""), inst_family: Ustr::from(""),
series_id: None,
inst_category: None,
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from(""),
settle_ccy: Ustr::from("USD"),
ct_val: "1".to_string(),
ct_mult: "1".to_string(),
ct_val_ccy: "USD".to_string(),
opt_type: crate::common::enums::OKXOptionType::None,
stk: String::new(),
list_time: None,
exp_time: None,
lever: String::new(),
tick_sz: "0.1".to_string(),
lot_sz: "1".to_string(),
min_sz: "1".to_string(),
ct_type: OKXContractType::Linear,
state: crate::common::enums::OKXInstrumentStatus::Preopen,
rule_type: String::new(),
max_lmt_sz: String::new(),
max_mkt_sz: String::new(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
inst_id_code: None,
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let result =
parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty underlying"));
}
#[rstest]
fn test_parse_futures_instrument_empty_underlying_returns_error() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Futures,
inst_id: Ustr::from("ETH-USD_UM-250328"),
uly: Ustr::from(""), inst_family: Ustr::from(""),
series_id: None,
inst_category: None,
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from(""),
settle_ccy: Ustr::from("USD"),
ct_val: "1".to_string(),
ct_mult: "1".to_string(),
ct_val_ccy: "USD".to_string(),
opt_type: crate::common::enums::OKXOptionType::None,
stk: String::new(),
list_time: None,
exp_time: Some(1743004800000),
lever: String::new(),
tick_sz: "0.1".to_string(),
lot_sz: "1".to_string(),
min_sz: "1".to_string(),
ct_type: OKXContractType::Linear,
state: crate::common::enums::OKXInstrumentStatus::Preopen,
rule_type: String::new(),
max_lmt_sz: String::new(),
max_mkt_sz: String::new(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
inst_id_code: None,
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let result =
parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty underlying"));
}
#[rstest]
fn test_parse_option_instrument_empty_opt_type_returns_error() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Option,
inst_id: Ustr::from("BTC-USD-250328-50000-C"),
uly: Ustr::from("BTC-USD"),
inst_family: Ustr::from("BTC-USD"),
series_id: None,
inst_category: None,
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from(""),
settle_ccy: Ustr::from("USD"),
ct_val: "0.01".to_string(),
ct_mult: "1".to_string(),
ct_val_ccy: "BTC".to_string(),
opt_type: crate::common::enums::OKXOptionType::None,
stk: "50000".to_string(),
list_time: None,
exp_time: Some(1743004800000),
lever: String::new(),
tick_sz: "0.0005".to_string(),
lot_sz: "0.1".to_string(),
min_sz: "0.1".to_string(),
ct_type: OKXContractType::Linear,
state: crate::common::enums::OKXInstrumentStatus::Preopen,
rule_type: String::new(),
max_lmt_sz: String::new(),
max_mkt_sz: String::new(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
inst_id_code: None,
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let result =
parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Unsupported") && err_msg.contains("optType"),
"expected Unsupported optType error, was: {err_msg}"
);
}
#[rstest]
fn test_parse_option_instrument_empty_underlying_returns_error() {
let instrument = OKXInstrument {
inst_type: OKXInstrumentType::Option,
inst_id: Ustr::from("BTC-USD-250328-50000-C"),
uly: Ustr::from(""), inst_family: Ustr::from(""),
series_id: None,
inst_category: None,
init_px_lmt_pct: String::new(),
float_px_lmt_pct: String::new(),
max_px_lmt_pct: String::new(),
base_ccy: Ustr::from(""),
quote_ccy: Ustr::from(""),
settle_ccy: Ustr::from("USD"),
ct_val: "0.01".to_string(),
ct_mult: "1".to_string(),
ct_val_ccy: "BTC".to_string(),
opt_type: crate::common::enums::OKXOptionType::Call,
stk: "50000".to_string(),
list_time: None,
exp_time: Some(1743004800000),
lever: String::new(),
tick_sz: "0.0005".to_string(),
lot_sz: "0.1".to_string(),
min_sz: "0.1".to_string(),
ct_type: OKXContractType::Linear,
state: crate::common::enums::OKXInstrumentStatus::Preopen,
rule_type: String::new(),
max_lmt_sz: String::new(),
max_mkt_sz: String::new(),
max_lmt_amt: String::new(),
max_mkt_amt: String::new(),
max_twap_sz: String::new(),
max_iceberg_sz: String::new(),
max_trigger_sz: String::new(),
max_stop_sz: String::new(),
inst_id_code: None,
rpi: None,
rpi_min_level: None,
rpi_min_px_band: None,
};
let result =
parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty underlying"));
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_short_usdt() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("ENA"),
liab: "130047.3610487126".to_string(),
spot_in_use_amt: "-129950".to_string(),
cross_liab: "130047.3610487126".to_string(),
eq: "-130047.3610487126".to_string(),
u_time: 1704067200000,
avail_bal: "0".to_string(),
avail_eq: "0".to_string(),
borrow_froz: "0".to_string(),
cash_bal: "0".to_string(),
dis_eq: "0".to_string(),
eq_usd: "0".to_string(),
smt_sync_eq: "0".to_string(),
spot_copy_trading_eq: "0".to_string(),
fixed_bal: "0".to_string(),
frozen_bal: "0".to_string(),
imr: "0".to_string(),
interest: "0".to_string(),
iso_eq: "0".to_string(),
iso_liab: "0".to_string(),
iso_upl: "0".to_string(),
max_loan: "0".to_string(),
mgn_ratio: "0".to_string(),
mmr: "0".to_string(),
notional_lever: "0".to_string(),
ord_frozen: "0".to_string(),
reward_bal: "0".to_string(),
cl_spot_in_use_amt: "0".to_string(),
max_spot_in_use_amt: "0".to_string(),
spot_iso_bal: "0".to_string(),
stgy_eq: "0".to_string(),
twap: "0".to_string(),
upl: "0".to_string(),
upl_liab: "0".to_string(),
spot_bal: "0".to_string(),
open_avg_px: "0".to_string(),
acc_avg_px: "0".to_string(),
spot_upl: "0".to_string(),
spot_upl_ratio: "0".to_string(),
total_pnl: "0".to_string(),
total_pnl_ratio: "0".to_string(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 2;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_some());
let report = result.unwrap();
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity.to_string(), "129950.00");
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_long() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("BTC"),
liab: "1.5".to_string(),
spot_in_use_amt: "1.2".to_string(),
cross_liab: "1.5".to_string(),
eq: "1.2".to_string(),
u_time: 1704067200000,
avail_bal: "0".to_string(),
avail_eq: "0".to_string(),
borrow_froz: "0".to_string(),
cash_bal: "0".to_string(),
dis_eq: "0".to_string(),
eq_usd: "0".to_string(),
smt_sync_eq: "0".to_string(),
spot_copy_trading_eq: "0".to_string(),
fixed_bal: "0".to_string(),
frozen_bal: "0".to_string(),
imr: "0".to_string(),
interest: "0".to_string(),
iso_eq: "0".to_string(),
iso_liab: "0".to_string(),
iso_upl: "0".to_string(),
max_loan: "0".to_string(),
mgn_ratio: "0".to_string(),
mmr: "0".to_string(),
notional_lever: "0".to_string(),
ord_frozen: "0".to_string(),
reward_bal: "0".to_string(),
cl_spot_in_use_amt: "0".to_string(),
max_spot_in_use_amt: "0".to_string(),
spot_iso_bal: "0".to_string(),
stgy_eq: "0".to_string(),
twap: "0".to_string(),
upl: "0".to_string(),
upl_liab: "0".to_string(),
spot_bal: "0".to_string(),
open_avg_px: "0".to_string(),
acc_avg_px: "0".to_string(),
spot_upl: "0".to_string(),
spot_upl_ratio: "0".to_string(),
total_pnl: "0".to_string(),
total_pnl_ratio: "0".to_string(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 8;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_some());
let report = result.unwrap();
assert_eq!(report.position_side, PositionSide::Long.as_specified());
assert_eq!(report.quantity.to_string(), "1.20000000");
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_usdc_quote() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("ETH"),
liab: "10.5".to_string(),
spot_in_use_amt: "-10.0".to_string(),
cross_liab: "10.5".to_string(),
eq: "-10.0".to_string(),
u_time: 1704067200000,
avail_bal: "0".to_string(),
avail_eq: "0".to_string(),
borrow_froz: "0".to_string(),
cash_bal: "0".to_string(),
dis_eq: "0".to_string(),
eq_usd: "0".to_string(),
smt_sync_eq: "0".to_string(),
spot_copy_trading_eq: "0".to_string(),
fixed_bal: "0".to_string(),
frozen_bal: "0".to_string(),
imr: "0".to_string(),
interest: "0".to_string(),
iso_eq: "0".to_string(),
iso_liab: "0".to_string(),
iso_upl: "0".to_string(),
max_loan: "0".to_string(),
mgn_ratio: "0".to_string(),
mmr: "0".to_string(),
notional_lever: "0".to_string(),
ord_frozen: "0".to_string(),
reward_bal: "0".to_string(),
cl_spot_in_use_amt: "0".to_string(),
max_spot_in_use_amt: "0".to_string(),
spot_iso_bal: "0".to_string(),
stgy_eq: "0".to_string(),
twap: "0".to_string(),
upl: "0".to_string(),
upl_liab: "0".to_string(),
spot_bal: "0".to_string(),
open_avg_px: "0".to_string(),
acc_avg_px: "0".to_string(),
spot_upl: "0".to_string(),
spot_upl_ratio: "0".to_string(),
total_pnl: "0".to_string(),
total_pnl_ratio: "0".to_string(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 6;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_some());
let report = result.unwrap();
assert_eq!(report.position_side, PositionSide::Short.as_specified());
assert_eq!(report.quantity.to_string(), "10.000000");
assert!(report.instrument_id.to_string().contains("ETH-"));
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_no_position() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("USDT"),
liab: "0".to_string(),
spot_in_use_amt: "0".to_string(),
cross_liab: "0".to_string(),
eq: "1000.5".to_string(),
u_time: 1704067200000,
avail_bal: "1000.5".to_string(),
avail_eq: "1000.5".to_string(),
borrow_froz: "0".to_string(),
cash_bal: "1000.5".to_string(),
dis_eq: "0".to_string(),
eq_usd: "1000.5".to_string(),
smt_sync_eq: "0".to_string(),
spot_copy_trading_eq: "0".to_string(),
fixed_bal: "0".to_string(),
frozen_bal: "0".to_string(),
imr: "0".to_string(),
interest: "0".to_string(),
iso_eq: "0".to_string(),
iso_liab: "0".to_string(),
iso_upl: "0".to_string(),
max_loan: "0".to_string(),
mgn_ratio: "0".to_string(),
mmr: "0".to_string(),
notional_lever: "0".to_string(),
ord_frozen: "0".to_string(),
reward_bal: "0".to_string(),
cl_spot_in_use_amt: "0".to_string(),
max_spot_in_use_amt: "0".to_string(),
spot_iso_bal: "0".to_string(),
stgy_eq: "0".to_string(),
twap: "0".to_string(),
upl: "0".to_string(),
upl_liab: "0".to_string(),
spot_bal: "1000.5".to_string(),
open_avg_px: "0".to_string(),
acc_avg_px: "0".to_string(),
spot_upl: "0".to_string(),
spot_upl_ratio: "0".to_string(),
total_pnl: "0".to_string(),
total_pnl_ratio: "0".to_string(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 2;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_none());
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("BTC"),
liab: "0.5".to_string(),
spot_in_use_amt: "0".to_string(),
cross_liab: "0.5".to_string(),
eq: "0".to_string(),
u_time: 1704067200000,
avail_bal: "0".to_string(),
avail_eq: "0".to_string(),
borrow_froz: "0".to_string(),
cash_bal: "0".to_string(),
dis_eq: "0".to_string(),
eq_usd: "0".to_string(),
smt_sync_eq: "0".to_string(),
spot_copy_trading_eq: "0".to_string(),
fixed_bal: "0".to_string(),
frozen_bal: "0".to_string(),
imr: "0".to_string(),
interest: "0".to_string(),
iso_eq: "0".to_string(),
iso_liab: "0".to_string(),
iso_upl: "0".to_string(),
max_loan: "0".to_string(),
mgn_ratio: "0".to_string(),
mmr: "0".to_string(),
notional_lever: "0".to_string(),
ord_frozen: "0".to_string(),
reward_bal: "0".to_string(),
cl_spot_in_use_amt: "0".to_string(),
max_spot_in_use_amt: "0".to_string(),
spot_iso_bal: "0".to_string(),
stgy_eq: "0".to_string(),
twap: "0".to_string(),
upl: "0".to_string(),
upl_liab: "0".to_string(),
spot_bal: "0".to_string(),
open_avg_px: "0".to_string(),
acc_avg_px: "0".to_string(),
spot_upl: "0".to_string(),
spot_upl_ratio: "0".to_string(),
total_pnl: "0".to_string(),
total_pnl_ratio: "0".to_string(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 8;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_none());
}
#[rstest]
fn test_parse_spot_margin_position_from_balance_empty_strings() {
let balance = OKXBalanceDetail {
ccy: Ustr::from("USDT"),
liab: String::new(),
spot_in_use_amt: String::new(),
cross_liab: String::new(),
eq: "5000.25".to_string(),
u_time: 1704067200000,
avail_bal: "5000.25".to_string(),
avail_eq: "5000.25".to_string(),
borrow_froz: String::new(),
cash_bal: "5000.25".to_string(),
dis_eq: String::new(),
eq_usd: "5000.25".to_string(),
smt_sync_eq: String::new(),
spot_copy_trading_eq: String::new(),
fixed_bal: String::new(),
frozen_bal: String::new(),
imr: String::new(),
interest: String::new(),
iso_eq: String::new(),
iso_liab: String::new(),
iso_upl: String::new(),
max_loan: String::new(),
mgn_ratio: String::new(),
mmr: String::new(),
notional_lever: String::new(),
ord_frozen: String::new(),
reward_bal: String::new(),
cl_spot_in_use_amt: String::new(),
max_spot_in_use_amt: String::new(),
spot_iso_bal: String::new(),
stgy_eq: String::new(),
twap: String::new(),
upl: String::new(),
upl_liab: String::new(),
spot_bal: "5000.25".to_string(),
open_avg_px: String::new(),
acc_avg_px: String::new(),
spot_upl: String::new(),
spot_upl_ratio: String::new(),
total_pnl: String::new(),
total_pnl_ratio: String::new(),
};
let account_id = AccountId::new("OKX-001");
let size_precision = 2;
let ts_init = UnixNanos::default();
let result = parse_spot_margin_position_from_balance(
&balance,
account_id,
InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
size_precision,
ts_init,
)
.unwrap();
assert!(result.is_none());
}
#[rstest]
#[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
#[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
#[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
#[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
#[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
#[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
#[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
fn test_okx_order_type_to_time_in_force(
#[case] okx_ord_type: OKXOrderType,
#[case] expected_tif: TimeInForce,
) {
let time_in_force = match okx_ord_type {
OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
_ => TimeInForce::Gtc,
};
assert_eq!(
time_in_force, expected_tif,
"OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
);
}
#[rstest]
fn test_fok_order_type_serialization() {
let ord_type = OKXOrderType::Fok;
let json = serde_json::to_string(&ord_type).expect("serialize");
assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
}
#[rstest]
fn test_ioc_order_type_serialization() {
let ord_type = OKXOrderType::Ioc;
let json = serde_json::to_string(&ord_type).expect("serialize");
assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
}
#[rstest]
fn test_optimal_limit_ioc_serialization() {
let ord_type = OKXOrderType::OptimalLimitIoc;
let json = serde_json::to_string(&ord_type).expect("serialize");
assert_eq!(
json, "\"optimal_limit_ioc\"",
"OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
);
}
#[rstest]
fn test_fok_order_type_deserialization() {
let json = "\"fok\"";
let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
assert_eq!(ord_type, OKXOrderType::Fok);
}
#[rstest]
fn test_ioc_order_type_deserialization() {
let json = "\"ioc\"";
let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
assert_eq!(ord_type, OKXOrderType::Ioc);
}
#[rstest]
fn test_optimal_limit_ioc_deserialization() {
let json = "\"optimal_limit_ioc\"";
let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
}
#[rstest]
#[case(TimeInForce::Fok, OKXOrderType::Fok)]
#[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
fn test_time_in_force_round_trip(
#[case] original_tif: TimeInForce,
#[case] expected_okx_type: OKXOrderType,
) {
let okx_ord_type = match original_tif {
TimeInForce::Fok => OKXOrderType::Fok,
TimeInForce::Ioc => OKXOrderType::Ioc,
TimeInForce::Gtc => OKXOrderType::Limit,
_ => OKXOrderType::Limit,
};
assert_eq!(okx_ord_type, expected_okx_type);
let parsed_tif = match okx_ord_type {
OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
_ => TimeInForce::Gtc,
};
assert_eq!(parsed_tif, original_tif);
}
#[rstest]
#[case::limit_fok(
OrderType::Limit,
TimeInForce::Fok,
OKXOrderType::Fok,
"Limit + FOK should map to Fok"
)]
#[case::limit_ioc(
OrderType::Limit,
TimeInForce::Ioc,
OKXOrderType::Ioc,
"Limit + IOC should map to Ioc"
)]
#[case::market_ioc(
OrderType::Market,
TimeInForce::Ioc,
OKXOrderType::OptimalLimitIoc,
"Market + IOC should map to OptimalLimitIoc"
)]
#[case::limit_gtc(
OrderType::Limit,
TimeInForce::Gtc,
OKXOrderType::Limit,
"Limit + GTC should map to Limit"
)]
#[case::market_gtc(
OrderType::Market,
TimeInForce::Gtc,
OKXOrderType::Market,
"Market + GTC should map to Market"
)]
fn test_order_type_time_in_force_combinations(
#[case] order_type: OrderType,
#[case] tif: TimeInForce,
#[case] expected_okx_type: OKXOrderType,
#[case] description: &str,
) {
let okx_ord_type = match (order_type, tif) {
(OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
(OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
(OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
_ => OKXOrderType::from(order_type),
};
assert_eq!(okx_ord_type, expected_okx_type, "{description}");
}
#[rstest]
fn test_market_fok_not_supported() {
let order_type = OrderType::Market;
let tif = TimeInForce::Fok;
let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
assert!(
is_market_fok,
"Market + FOK combination should be identified for rejection"
);
}
#[rstest]
#[case::empty_string("", true)]
#[case::zero("0", true)]
#[case::minus_one("-1", true)]
#[case::minus_two("-2", true)]
#[case::normal_price("100.5", false)]
#[case::another_price("0.001", false)]
fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
assert_eq!(is_market_price(price), expected);
}
#[rstest]
#[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
#[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
#[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
#[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
#[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
#[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
#[case::optimal_limit_ioc_market_minus_one(
OKXOrderType::OptimalLimitIoc,
"-1",
OrderType::Market
)]
#[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
#[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
#[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
fn test_determine_order_type(
#[case] okx_ord_type: OKXOrderType,
#[case] price: &str,
#[case] expected: OrderType,
) {
assert_eq!(determine_order_type(okx_ord_type, price), expected);
}
#[rstest]
#[case::option("BTC-USD-250328-92000-C", "BTC-USD")]
#[case::swap("BTC-USDT-SWAP", "BTC-USDT")]
#[case::futures("ETH-USD-250328", "ETH-USD")]
#[case::spot("BTC-USDT", "BTC-USDT")]
fn test_extract_inst_family(#[case] symbol: &str, #[case] expected: &str) {
let family = extract_inst_family(symbol).unwrap();
assert_eq!(family.as_str(), expected);
}
#[rstest]
fn test_extract_inst_family_single_segment_fails() {
extract_inst_family("BTC").unwrap_err();
}
#[rstest]
#[case("BTC-USDT", OKXInstrumentType::Spot)]
#[case("BTC-USDT-SWAP", OKXInstrumentType::Swap)]
#[case("BTC-USDT-250328", OKXInstrumentType::Futures)]
#[case("BTC-USD-250328-50000-C", OKXInstrumentType::Option)]
#[case("BTC-ABOVE-DAILY-260224-1600-65000", OKXInstrumentType::Events)]
fn test_okx_instrument_type_from_symbol(
#[case] symbol: &str,
#[case] expected: OKXInstrumentType,
) {
assert_eq!(okx_instrument_type_from_symbol(symbol), expected);
}
#[rstest]
#[case(OKXInstrumentStatus::Live, MarketStatusAction::Trading)]
#[case(OKXInstrumentStatus::Suspend, MarketStatusAction::Suspend)]
#[case(OKXInstrumentStatus::Preopen, MarketStatusAction::PreOpen)]
#[case(OKXInstrumentStatus::Test, MarketStatusAction::NotAvailableForTrading)]
#[case(OKXInstrumentStatus::PostOnly, MarketStatusAction::Quoting)]
#[case(
OKXInstrumentStatus::Rebase,
MarketStatusAction::NotAvailableForTrading
)]
#[case(
OKXInstrumentStatus::Settling,
MarketStatusAction::NotAvailableForTrading
)]
#[case(
OKXInstrumentStatus::Unknown,
MarketStatusAction::NotAvailableForTrading
)]
fn test_okx_status_to_market_action(
#[case] status: OKXInstrumentStatus,
#[case] expected: MarketStatusAction,
) {
assert_eq!(okx_status_to_market_action(status), expected);
}
#[rstest]
#[case::future_state("\"future_state_xyz\"")]
#[case::frozen("\"frozen\"")]
#[case::delisting("\"delisting\"")]
fn test_okx_unknown_status_falls_back(#[case] json: &str) {
let parsed: OKXInstrumentStatus = serde_json::from_str(json).unwrap();
assert_eq!(parsed, OKXInstrumentStatus::Unknown);
assert_eq!(
okx_status_to_market_action(parsed),
MarketStatusAction::NotAvailableForTrading
);
}
#[rstest]
#[case::crypto("\"1\"", OKXInstrumentCategory::Crypto, AssetClass::Cryptocurrency)]
#[case::equity("\"3\"", OKXInstrumentCategory::Equity, AssetClass::Equity)]
#[case::commodity("\"4\"", OKXInstrumentCategory::Commodity, AssetClass::Commodity)]
#[case::fx("\"5\"", OKXInstrumentCategory::Fx, AssetClass::FX)]
#[case::debt("\"6\"", OKXInstrumentCategory::Debt, AssetClass::Debt)]
#[case::unknown_code("\"2\"", OKXInstrumentCategory::Unknown, AssetClass::Alternative)]
fn test_okx_inst_category_parsing_and_asset_class(
#[case] json: &str,
#[case] expected: OKXInstrumentCategory,
#[case] asset_class: AssetClass,
) {
let parsed: OKXInstrumentCategory = serde_json::from_str(json).unwrap();
assert_eq!(parsed, expected);
assert_eq!(okx_inst_category_to_asset_class(Some(parsed)), asset_class);
}
#[rstest]
fn test_okx_instrument_reads_inst_category_and_ignores_legacy_category() {
let json = crate::common::testing::load_test_json("http_get_instruments_spot.json");
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
let item = &mut value["data"][0];
assert_eq!(item["category"], serde_json::json!("1"));
item["instCategory"] = serde_json::json!("3");
let instrument: OKXInstrument = serde_json::from_value(item.clone()).unwrap();
assert_eq!(
instrument.inst_category,
Some(OKXInstrumentCategory::Equity)
);
assert_eq!(
okx_inst_category_to_asset_class(instrument.inst_category),
AssetClass::Equity
);
}
#[rstest]
fn test_rpi_instrument_permission_parses_current_and_legacy_fields() {
let json = load_test_json("http_get_instruments_spot.json");
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
let mut current = value["data"][0].clone();
current["rpi"] = serde_json::json!("2");
let current: OKXInstrument = serde_json::from_value(current).unwrap();
let legacy = &mut value["data"][1];
legacy["elp"] = serde_json::json!("1");
let legacy: OKXInstrument = serde_json::from_value(legacy.clone()).unwrap();
assert_eq!(
current.rpi,
Some(crate::common::enums::OKXRpiPermission::Permitted)
);
assert_eq!(
legacy.rpi,
Some(crate::common::enums::OKXRpiPermission::Enabled)
);
}
#[rstest]
fn test_rpi_instrument_spacing_fields_are_typed_and_reachable() {
let json = load_test_json("http_get_instruments_spot.json");
let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json).unwrap();
let okx_inst = response
.data
.first()
.expect("Test data must have an instrument");
assert_eq!(okx_inst.rpi_min_level, Some(5));
assert_eq!(okx_inst.rpi_min_px_band, Some(Decimal::from(20)));
let instrument =
parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
let InstrumentAny::CurrencyPair(pair) = instrument else {
panic!("expected CurrencyPair");
};
let info = pair.info.expect("RPI spacing info must be set");
assert_eq!(info.get_u64("okx_rpi_min_level"), Some(5));
assert_eq!(info.get_str("okx_rpi_min_px_band"), Some("20"));
}
}