use derive_builder::Builder;
use nautilus_model::{
data::{Data, FundingRateUpdate, InstrumentStatus, OrderBookDeltas},
events::{
AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderExpired,
OrderModifyRejected, OrderRejected, OrderTriggered, OrderUpdated,
},
identifiers::ClientOrderId,
instruments::InstrumentAny,
reports::{FillReport, OrderStatusReport, PositionStatusReport},
};
use serde::{Deserialize, Serialize};
use ustr::Ustr;
use super::enums::{OKXWsChannel, OKXWsOperation};
use crate::{
common::{
enums::{
OKXAlgoOrderStatus, OKXAlgoOrderType, OKXBookAction, OKXCandleConfirm, OKXExecType,
OKXInstrumentType, OKXOrderCategory, OKXOrderStatus, OKXOrderType, OKXPositionSide,
OKXPriceType, OKXQuickMarginType, OKXSelfTradePreventionMode, OKXSettlementState,
OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
},
models::{OKXInstrument, OKXRpiBookLevel},
parse::{
deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
deserialize_string_to_u64, deserialize_target_currency_as_none,
},
},
http::models::OKXSpreadOrder,
websocket::enums::OKXSubscriptionEvent,
};
#[derive(Debug, Clone)]
pub enum NautilusWsMessage {
Data(Vec<Data>),
Deltas(OrderBookDeltas),
FundingRates(Vec<FundingRateUpdate>),
Instrument(Box<InstrumentAny>, Option<InstrumentStatus>),
InstrumentStatus(InstrumentStatus),
AccountUpdate(AccountState),
PositionUpdate(PositionStatusReport),
OrderAccepted(OrderAccepted),
OrderCanceled(OrderCanceled),
OrderExpired(OrderExpired),
OrderRejected(OrderRejected),
OrderCancelRejected(OrderCancelRejected),
OrderModifyRejected(OrderModifyRejected),
OrderTriggered(OrderTriggered),
OrderUpdated(OrderUpdated),
ExecutionReports(Vec<ExecutionReport>),
Error(OKXWebSocketError),
Raw(serde_json::Value), Reconnected,
Authenticated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
)]
pub struct OKXWebSocketError {
pub code: String,
pub message: String,
pub conn_id: Option<String>,
pub timestamp: u64,
}
#[derive(Debug, Clone)]
#[allow(
clippy::large_enum_variant,
reason = "the variant size gap only crosses the threshold when high-precision widens the raw types"
)]
pub enum ExecutionReport {
Order(OrderStatusReport),
Fill(FillReport),
}
#[derive(Debug)]
pub enum OKXWsMessage {
BookData {
arg: OKXWebSocketArg,
action: OKXBookAction,
data: Vec<OKXBookMsg>,
},
RpiBookData {
arg: OKXWebSocketArg,
action: OKXBookAction,
data: Vec<OKXRpiBookMsg>,
},
ChannelData {
channel: OKXWsChannel,
inst_id: Option<Ustr>,
data: serde_json::Value,
},
OrderResponse {
id: Option<String>,
op: OKXWsOperation,
code: String,
msg: String,
data: Vec<serde_json::Value>,
},
Orders(Vec<OKXOrderMsg>),
SpreadOrders(Vec<OKXSpreadOrder>),
AlgoOrders(Vec<OKXAlgoOrderMsg>),
Account(serde_json::Value),
Positions(serde_json::Value),
Instruments(Vec<OKXInstrument>),
SendFailed {
request_id: String,
client_order_id: Option<ClientOrderId>,
op: Option<OKXWsOperation>,
error: String,
},
Error(OKXWebSocketError),
Reconnected,
Authenticated,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXWsRequest<T> {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub op: OKXWsOperation,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp_time: Option<String>,
pub args: Vec<T>,
}
#[derive(Debug, Serialize)]
pub struct OKXAuthentication {
pub op: &'static str,
pub args: Vec<OKXAuthenticationArg>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXAuthenticationArg {
pub api_key: String,
pub passphrase: String,
pub timestamp: String,
pub sign: String,
}
#[derive(Debug, Serialize)]
pub struct OKXSubscription {
pub op: OKXWsOperation,
pub args: Vec<OKXSubscriptionArg>,
}
#[derive(Clone, Debug)]
pub struct OKXSubscriptionArg {
pub channel: OKXWsChannel,
pub inst_type: Option<OKXInstrumentType>,
pub inst_family: Option<Ustr>,
pub inst_id: Option<Ustr>,
}
impl Serialize for OKXSubscriptionArg {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("channel", &self.channel)?;
if let Some(inst_type) = &self.inst_type {
map.serialize_entry("instType", inst_type)?;
}
if let Some(inst_family) = &self.inst_family {
map.serialize_entry("instFamily", inst_family)?;
}
if let Some(inst_id) = &self.inst_id {
let key = if self.channel.is_spread() {
"sprdId"
} else {
"instId"
};
map.serialize_entry(key, inst_id)?;
}
map.end()
}
}
#[derive(Debug)]
pub enum OKXWsFrame {
Login {
event: String,
code: String,
msg: String,
conn_id: String,
},
Subscription {
event: OKXSubscriptionEvent,
arg: OKXWebSocketArg,
conn_id: String,
code: Option<String>,
msg: Option<String>,
},
ChannelConnCount {
event: String,
channel: OKXWsChannel,
conn_count: String,
conn_id: String,
},
OrderResponse {
id: Option<String>,
op: OKXWsOperation,
code: String,
msg: String,
data: Vec<serde_json::Value>,
},
BookData {
arg: OKXWebSocketArg,
action: OKXBookAction,
data: Vec<OKXBookMsg>,
},
RpiBookData {
arg: OKXWebSocketArg,
action: OKXBookAction,
data: Vec<OKXRpiBookMsg>,
},
Data {
arg: OKXWebSocketArg,
data: serde_json::Value,
},
Error {
code: String,
msg: String,
},
Ping,
Reconnected,
}
impl<'de> Deserialize<'de> for OKXWsFrame {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let mut value = serde_json::Value::deserialize(deserializer)?;
let obj = value
.as_object_mut()
.ok_or_else(|| D::Error::custom("expected JSON object for OKXWsFrame"))?;
if let Some(event) = obj.get("event").and_then(|v| v.as_str()) {
match event {
"login" => return parse_login(obj),
"subscribe" | "unsubscribe" => return parse_subscription(obj),
"error" => return parse_error(obj),
_ if obj.contains_key("channel") && obj.contains_key("connCount") => {
return parse_channel_conn_count(obj);
}
_ => {}
}
}
if obj.contains_key("op") {
return parse_order_response(obj);
}
if obj.contains_key("action") && obj.contains_key("arg") {
return parse_book_data(obj);
}
if obj.contains_key("arg") && obj.contains_key("data") {
return parse_data(obj);
}
if obj.contains_key("code") && obj.contains_key("msg") {
return parse_error(obj);
}
Err(D::Error::custom(format!(
"cannot determine OKXWsFrame variant from: {}",
serde_json::to_string(&value).unwrap_or_default()
)))
}
}
#[inline]
fn take_str<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &'static str,
) -> Result<String, E> {
match obj.remove(key) {
Some(serde_json::Value::String(s)) => Ok(s),
Some(_) => Err(E::custom(format!("field `{key}` is not a string"))),
None => Err(E::missing_field(key)),
}
}
#[inline]
fn take_optional_str(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &'static str,
) -> Option<String> {
match obj.remove(key) {
Some(serde_json::Value::String(s)) => Some(s),
_ => None,
}
}
fn parse_login<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
Ok(OKXWsFrame::Login {
event: take_str(obj, "event")?,
code: take_str(obj, "code")?,
msg: take_str(obj, "msg")?,
conn_id: take_str(obj, "connId")?,
})
}
fn parse_subscription<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
let event_val = obj
.remove("event")
.ok_or_else(|| E::missing_field("event"))?;
let event: OKXSubscriptionEvent =
serde_json::from_value(event_val).map_err(|e| E::custom(format!("invalid event: {e}")))?;
let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
let arg: OKXWebSocketArg =
serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
Ok(OKXWsFrame::Subscription {
event,
arg,
conn_id: take_str(obj, "connId")?,
code: take_optional_str(obj, "code"),
msg: take_optional_str(obj, "msg"),
})
}
fn parse_channel_conn_count<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
let channel_val = obj
.remove("channel")
.ok_or_else(|| E::missing_field("channel"))?;
let channel: OKXWsChannel = serde_json::from_value(channel_val)
.map_err(|e| E::custom(format!("invalid channel: {e}")))?;
Ok(OKXWsFrame::ChannelConnCount {
event: take_str(obj, "event")?,
channel,
conn_count: take_str(obj, "connCount")?,
conn_id: take_str(obj, "connId")?,
})
}
fn parse_order_response<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
let op_val = obj.remove("op").ok_or_else(|| E::missing_field("op"))?;
let op: OKXWsOperation =
serde_json::from_value(op_val).map_err(|e| E::custom(format!("invalid op: {e}")))?;
let data: Vec<serde_json::Value> = match obj.remove("data") {
Some(v) => {
serde_json::from_value(v).map_err(|e| E::custom(format!("invalid data: {e}")))?
}
None => Vec::new(),
};
Ok(OKXWsFrame::OrderResponse {
id: take_optional_str(obj, "id"),
op,
code: take_str(obj, "code")?,
msg: take_str(obj, "msg")?,
data,
})
}
fn parse_book_data<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
let arg: OKXWebSocketArg =
serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
let action_val = obj
.remove("action")
.ok_or_else(|| E::missing_field("action"))?;
let action: OKXBookAction = serde_json::from_value(action_val)
.map_err(|e| E::custom(format!("invalid action: {e}")))?;
let data_val = obj.remove("data").ok_or_else(|| E::missing_field("data"))?;
if arg.channel == OKXWsChannel::BooksRpi {
let data: Vec<OKXRpiBookMsg> = serde_json::from_value(data_val)
.map_err(|e| E::custom(format!("invalid data: {e}")))?;
return Ok(OKXWsFrame::RpiBookData { arg, action, data });
}
let data: Vec<OKXBookMsg> =
serde_json::from_value(data_val).map_err(|e| E::custom(format!("invalid data: {e}")))?;
Ok(OKXWsFrame::BookData { arg, action, data })
}
fn parse_data<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
let arg: OKXWebSocketArg =
serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
let data = obj.remove("data").ok_or_else(|| E::missing_field("data"))?;
Ok(OKXWsFrame::Data { arg, data })
}
fn parse_error<E: serde::de::Error>(
obj: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<OKXWsFrame, E> {
Ok(OKXWsFrame::Error {
code: take_str(obj, "code")?,
msg: take_str(obj, "msg")?,
})
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXWebSocketArg {
pub channel: OKXWsChannel,
#[serde(default, alias = "sprdId")]
pub inst_id: Option<Ustr>,
#[serde(default)]
pub inst_type: Option<OKXInstrumentType>,
#[serde(default)]
pub inst_family: Option<Ustr>,
#[serde(default)]
pub bar: Option<Ustr>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXTickerMsg {
pub inst_type: OKXInstrumentType,
pub inst_id: Ustr,
#[serde(rename = "last")]
pub last_px: String,
pub last_sz: String,
pub ask_px: String,
pub ask_sz: String,
pub bid_px: String,
pub bid_sz: String,
pub open24h: String,
pub high24h: String,
pub low24h: String,
pub vol_ccy_24h: String,
pub vol24h: String,
pub sod_utc0: String,
pub sod_utc8: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OrderBookEntry {
pub price: String,
pub size: String,
#[serde(default)]
pub liquidated_orders_count: String,
#[serde(default)]
pub orders_count: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXBookMsg {
pub asks: Vec<OrderBookEntry>,
pub bids: Vec<OrderBookEntry>,
pub checksum: Option<i64>,
pub prev_seq_id: Option<i64>,
pub seq_id: u64,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OKXRpiBookMsg {
pub asks: Vec<OKXRpiBookLevel>,
pub bids: Vec<OKXRpiBookLevel>,
pub prev_seq_id: i64,
pub seq_id: u64,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXTradeMsg {
#[serde(default, alias = "sprdId")]
pub inst_id: Ustr,
pub trade_id: String,
pub px: String,
pub sz: String,
pub side: OKXSide,
#[serde(default)]
pub count: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub seq_id: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXFundingRateMsg {
#[serde(default)]
pub inst_type: Option<OKXInstrumentType>,
pub inst_id: Ustr,
pub funding_rate: Ustr,
pub next_funding_rate: Ustr,
#[serde(default)]
pub min_funding_rate: Option<String>,
#[serde(default)]
pub max_funding_rate: Option<String>,
#[serde(default)]
pub sett_state: OKXSettlementState,
#[serde(default)]
pub sett_funding_rate: Option<String>,
#[serde(default)]
pub premium: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub funding_time: u64,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub next_funding_time: u64,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXMarkPriceMsg {
pub inst_id: Ustr,
pub mark_px: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXIndexPriceMsg {
pub inst_id: Ustr,
pub idx_px: String,
pub high24h: String,
pub low24h: String,
pub open24h: String,
pub sod_utc0: String,
pub sod_utc8: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXPriceLimitMsg {
pub inst_id: Ustr,
pub buy_lmt: String,
pub sell_lmt: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXCandleMsg {
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
pub o: String,
pub h: String,
pub l: String,
pub c: String,
pub vol: String,
pub vol_ccy: String,
pub vol_ccy_quote: String,
pub confirm: OKXCandleConfirm,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXOpenInterestMsg {
pub inst_id: Ustr,
pub oi: String,
pub oi_ccy: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXOptionSummaryMsg {
#[serde(default)]
pub inst_type: Option<OKXInstrumentType>,
pub inst_id: Ustr,
pub uly: String,
pub delta: String,
pub gamma: String,
pub theta: String,
pub vega: String,
#[serde(alias = "deltaBS")]
pub delta_bs: String,
#[serde(alias = "gammaBS")]
pub gamma_bs: String,
#[serde(alias = "thetaBS")]
pub theta_bs: String,
#[serde(alias = "vegaBS")]
pub vega_bs: String,
pub real_vol: String,
pub bid_vol: String,
pub ask_vol: String,
pub mark_vol: String,
pub lever: String,
#[serde(default)]
pub fwd_px: Option<String>,
#[serde(default)]
pub mark_px: Option<String>,
#[serde(default)]
pub vol_lv: Option<String>,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXEstimatedPriceMsg {
pub inst_id: Ustr,
pub settle_px: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXStatusMsg {
pub title: Ustr,
#[serde(rename = "type")]
pub status_type: Ustr,
pub state: Ustr,
pub end_time: Option<String>,
pub begin_time: Option<String>,
pub service_type: Option<Ustr>,
pub reason: Option<String>,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub ts: u64,
}
pub use crate::common::models::OKXAttachedAlgoOrd;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXLinkedAlgoOrd {
#[serde(default)]
pub algo_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXOrderMsg {
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub acc_fill_sz: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub algo_id: Option<String>,
pub avg_px: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub c_time: u64,
#[serde(default)]
pub cancel_source: Option<String>,
#[serde(default)]
pub cancel_source_reason: Option<String>,
pub category: OKXOrderCategory,
pub ccy: Ustr,
pub cl_ord_id: String,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub algo_cl_ord_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub attach_algo_cl_ord_id: Option<String>,
#[serde(default)]
pub attach_algo_ords: Vec<OKXAttachedAlgoOrd>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub outcome: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fee: Option<String>,
pub fee_ccy: Ustr,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_fee: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
pub fill_fee_ccy: Option<Ustr>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_mark_px: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_mark_vol: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_px_vol: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_px_usd: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_fwd_px: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_notional_usd: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub fill_pnl: Option<String>,
pub fill_px: String,
pub fill_sz: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub fill_time: u64,
pub inst_id: Ustr,
pub inst_type: OKXInstrumentType,
#[serde(default)]
pub is_tp_limit: Option<String>,
pub lever: String,
#[serde(default)]
pub linked_algo_ord: Option<OKXLinkedAlgoOrd>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub notional_usd: Option<String>,
pub ord_id: Ustr,
pub ord_type: OKXOrderType,
pub pnl: String,
pub pos_side: OKXPositionSide,
#[serde(default)]
pub px: String,
#[serde(default)]
pub px_type: OKXPriceType,
#[serde(default)]
pub px_usd: Option<String>,
#[serde(default)]
pub px_vol: Option<String>,
#[serde(default)]
pub quick_mgn_type: OKXQuickMarginType,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub rebate: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
pub rebate_ccy: Option<Ustr>,
pub reduce_only: String,
pub side: OKXSide,
#[serde(default)]
pub sl_ord_px: Option<String>,
#[serde(default)]
pub sl_trigger_px: Option<String>,
#[serde(default)]
pub sl_trigger_px_type: Option<OKXTriggerType>,
#[serde(default)]
pub source: Option<String>,
pub state: OKXOrderStatus,
#[serde(default)]
pub stp_id: Option<String>,
#[serde(default)]
pub stp_mode: OKXSelfTradePreventionMode,
pub exec_type: OKXExecType,
pub sz: String,
#[serde(default)]
pub tag: Option<String>,
pub td_mode: OKXTradeMode,
#[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
pub tgt_ccy: Option<OKXTargetCurrency>,
#[serde(default)]
pub tp_ord_px: Option<String>,
#[serde(default)]
pub tp_trigger_px: Option<String>,
#[serde(default)]
pub tp_trigger_px_type: Option<OKXTriggerType>,
pub trade_id: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub u_time: u64,
#[serde(default)]
pub amend_result: Option<String>,
#[serde(default)]
pub req_id: Option<String>,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub msg: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OKXAlgoOrderMsg {
pub algo_id: String,
#[serde(default)]
pub algo_cl_ord_id: String,
pub cl_ord_id: String,
pub ord_id: String,
pub inst_id: Ustr,
pub inst_type: OKXInstrumentType,
pub ord_type: OKXAlgoOrderType,
pub state: OKXAlgoOrderStatus,
pub side: OKXSide,
pub pos_side: OKXPositionSide,
#[serde(default)]
pub sz: String,
#[serde(default)]
pub trigger_px: String,
#[serde(default)]
pub trigger_px_type: OKXTriggerType,
#[serde(default)]
pub sl_trigger_px: String,
#[serde(default)]
pub sl_ord_px: String,
#[serde(default)]
pub sl_trigger_px_type: OKXTriggerType,
#[serde(default)]
pub tp_trigger_px: String,
#[serde(default)]
pub tp_ord_px: String,
#[serde(default)]
pub tp_trigger_px_type: OKXTriggerType,
#[serde(default)]
pub ord_px: String,
pub td_mode: OKXTradeMode,
pub lever: String,
#[serde(default)]
pub reduce_only: String,
#[serde(default)]
pub close_fraction: String,
#[serde(default)]
pub actual_px: String,
#[serde(default)]
pub actual_sz: String,
#[serde(default)]
pub notional_usd: String,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub c_time: u64,
#[serde(deserialize_with = "deserialize_string_to_u64")]
pub u_time: u64,
#[serde(default)]
pub trigger_time: String,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub callback_ratio: String,
#[serde(default)]
pub callback_spread: String,
#[serde(default)]
pub active_px: String,
#[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
pub ccy: Option<Ustr>,
#[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
pub tgt_ccy: Option<OKXTargetCurrency>,
#[serde(default)]
pub fee: Option<String>,
#[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
pub fee_ccy: Option<Ustr>,
#[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
pub advance_ord_type: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
#[builder(default)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsAttachAlgoOrdParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub attach_algo_cl_ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sl_trigger_px: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sl_ord_px: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sl_trigger_px_type: Option<OKXTriggerType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tp_trigger_px: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tp_ord_px: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tp_trigger_px_type: Option<OKXTriggerType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_ratio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_spread: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_px: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_callback_ratio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_callback_spread: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_active_px: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsPostOrderParams {
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub inst_type: Option<OKXInstrumentType>,
pub inst_id_code: u64,
pub td_mode: OKXTradeMode,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub ccy: Option<Ustr>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
pub side: OKXSide,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub pos_side: Option<OKXPositionSide>,
pub ord_type: OKXOrderType,
pub sz: String,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub px: Option<String>,
#[builder(default)]
#[serde(rename = "pxUsd", skip_serializing_if = "Option::is_none")]
pub px_usd: Option<String>,
#[builder(default)]
#[serde(rename = "pxVol", skip_serializing_if = "Option::is_none")]
pub px_vol: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub reduce_only: Option<bool>,
#[builder(default)]
#[serde(rename = "closePosition", skip_serializing_if = "Option::is_none")]
pub close_position: Option<bool>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub tgt_ccy: Option<OKXTargetCurrency>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_bump: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub slippage_pct: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub rpi_taker_access: Option<bool>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub rpi_px_round: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
#[builder(default)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsCancelOrderParams {
pub inst_id_code: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
#[builder(default)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsMassCancelParams {
pub inst_type: OKXInstrumentType,
pub inst_family: Ustr,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
#[builder(default)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsAmendOrderParams {
pub inst_id_code: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_px: Option<String>,
#[serde(rename = "newPxUsd", skip_serializing_if = "Option::is_none")]
pub new_px_usd: Option<String>,
#[serde(rename = "newPxVol", skip_serializing_if = "Option::is_none")]
pub new_px_vol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_sz: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rpi_taker_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rpi_px_round: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_bump: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsPostAlgoOrderParams {
pub inst_id_code: u64,
pub td_mode: OKXTradeMode,
pub side: OKXSide,
pub ord_type: OKXAlgoOrderType,
pub sz: String,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub pos_side: Option<OKXPositionSide>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger_px: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger_px_type: Option<OKXTriggerType>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub order_px: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub reduce_only: Option<bool>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_ratio: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_spread: Option<String>,
#[builder(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub active_px: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
#[builder(setter(into, strip_option))]
#[serde(rename_all = "camelCase")]
pub struct WsCancelAlgoOrderParams {
pub inst_id_code: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub algo_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub algo_cl_ord_id: Option<String>,
}
#[cfg(test)]
mod tests {
use nautilus_core::time::get_atomic_clock_realtime;
use rstest::rstest;
use rust_decimal::Decimal;
use super::*;
use crate::common::testing::load_test_json;
#[rstest]
fn test_deserialize_websocket_arg() {
let json_str = r#"{"channel":"instruments","instType":"SPOT"}"#;
let result: Result<OKXWebSocketArg, _> = serde_json::from_str(json_str);
match result {
Ok(arg) => {
assert_eq!(arg.channel, OKXWsChannel::Instruments);
assert_eq!(arg.inst_type, Some(OKXInstrumentType::Spot));
assert_eq!(arg.inst_id, None);
}
Err(e) => {
panic!("Failed to deserialize WebSocket arg: {e}");
}
}
}
#[rstest]
fn test_deserialize_subscribe_variant_direct() {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubscribeMsg {
event: String,
arg: OKXWebSocketArg,
conn_id: String,
}
let json_str = r#"{"event":"subscribe","arg":{"channel":"instruments","instType":"SPOT"},"connId":"380cfa6a"}"#;
let result: Result<SubscribeMsg, _> = serde_json::from_str(json_str);
match result {
Ok(msg) => {
assert_eq!(msg.event, "subscribe");
assert_eq!(msg.arg.channel, OKXWsChannel::Instruments);
assert_eq!(msg.conn_id, "380cfa6a");
}
Err(e) => {
panic!("Failed to deserialize subscribe message directly: {e}");
}
}
}
#[rstest]
fn test_deserialize_subscribe_confirmation() {
let json_str = r#"{"event":"subscribe","arg":{"channel":"instruments","instType":"SPOT"},"connId":"380cfa6a"}"#;
let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
match result {
Ok(msg) => {
if let OKXWsFrame::Subscription {
event,
arg,
conn_id,
..
} = msg
{
assert_eq!(event, OKXSubscriptionEvent::Subscribe);
assert_eq!(arg.channel, OKXWsChannel::Instruments);
assert_eq!(conn_id, "380cfa6a");
} else {
panic!("Expected Subscribe variant, was: {msg:?}");
}
}
Err(e) => {
panic!("Failed to deserialize subscription confirmation: {e}");
}
}
}
#[rstest]
fn test_deserialize_subscribe_with_inst_id() {
let json_str = r#"{"event":"subscribe","arg":{"channel":"candle1m","instId":"ETH-USDT"},"connId":"358602f5"}"#;
let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
match result {
Ok(msg) => {
if let OKXWsFrame::Subscription {
event,
arg,
conn_id,
..
} = msg
{
assert_eq!(event, OKXSubscriptionEvent::Subscribe);
assert_eq!(arg.channel, OKXWsChannel::Candle1Minute);
assert_eq!(conn_id, "358602f5");
} else {
panic!("Expected Subscribe variant, was: {msg:?}");
}
}
Err(e) => {
panic!("Failed to deserialize subscription confirmation: {e}");
}
}
}
#[rstest]
fn test_channel_serialization_for_logging() {
let channel = OKXWsChannel::Candle1Minute;
let serialized = serde_json::to_string(&channel).unwrap();
let cleaned = serialized.trim_matches('"').to_string();
assert_eq!(cleaned, "candle1m");
let channel = OKXWsChannel::BboTbt;
let serialized = serde_json::to_string(&channel).unwrap();
let cleaned = serialized.trim_matches('"').to_string();
assert_eq!(cleaned, "bbo-tbt");
let channel = OKXWsChannel::Trades;
let serialized = serde_json::to_string(&channel).unwrap();
let cleaned = serialized.trim_matches('"').to_string();
assert_eq!(cleaned, "trades");
}
#[rstest]
fn test_order_response_with_enum_operation() {
let json_str = r#"{"id":"req-123","op":"order","code":"0","msg":"","data":[]}"#;
let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
match result {
Ok(OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
}) => {
assert_eq!(id, Some("req-123".to_string()));
assert_eq!(op, OKXWsOperation::Order);
assert_eq!(code, "0");
assert_eq!(msg, "");
assert!(data.is_empty());
}
Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
Err(e) => panic!("Failed to deserialize: {e}"),
}
let json_str = r#"{"id":"cancel-456","op":"cancel-order","code":"50001","msg":"Order not found","data":[]}"#;
let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
match result {
Ok(OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
}) => {
assert_eq!(id, Some("cancel-456".to_string()));
assert_eq!(op, OKXWsOperation::CancelOrder);
assert_eq!(code, "50001");
assert_eq!(msg, "Order not found");
assert!(data.is_empty());
}
Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
Err(e) => panic!("Failed to deserialize: {e}"),
}
let json_str = r#"{"id":"amend-789","op":"amend-order","code":"50002","msg":"Invalid price","data":[]}"#;
let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
match result {
Ok(OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
}) => {
assert_eq!(id, Some("amend-789".to_string()));
assert_eq!(op, OKXWsOperation::AmendOrder);
assert_eq!(code, "50002");
assert_eq!(msg, "Invalid price");
assert!(data.is_empty());
}
Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
Err(e) => panic!("Failed to deserialize: {e}"),
}
}
#[rstest]
fn test_operation_enum_serialization() {
let op = OKXWsOperation::Order;
let serialized = serde_json::to_string(&op).unwrap();
assert_eq!(serialized, "\"order\"");
let op = OKXWsOperation::CancelOrder;
let serialized = serde_json::to_string(&op).unwrap();
assert_eq!(serialized, "\"cancel-order\"");
let op = OKXWsOperation::AmendOrder;
let serialized = serde_json::to_string(&op).unwrap();
assert_eq!(serialized, "\"amend-order\"");
let op = OKXWsOperation::Subscribe;
let serialized = serde_json::to_string(&op).unwrap();
assert_eq!(serialized, "\"subscribe\"");
}
#[rstest]
fn test_order_response_parsing() {
let success_response = r#"{
"id": "req-123",
"op": "order",
"code": "0",
"msg": "",
"data": [{"sMsg": "Order placed successfully"}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(success_response).unwrap();
match parsed {
OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
} => {
assert_eq!(id, Some("req-123".to_string()));
assert_eq!(op, OKXWsOperation::Order);
assert_eq!(code, "0");
assert_eq!(msg, "");
assert_eq!(data.len(), 1);
}
_ => panic!("Expected OrderResponse variant"),
}
let failure_response = r#"{
"id": "req-456",
"op": "cancel-order",
"code": "50001",
"msg": "Order not found",
"data": [{"sMsg": "Order with client order ID not found"}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(failure_response).unwrap();
match parsed {
OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
} => {
assert_eq!(id, Some("req-456".to_string()));
assert_eq!(op, OKXWsOperation::CancelOrder);
assert_eq!(code, "50001");
assert_eq!(msg, "Order not found");
assert_eq!(data.len(), 1);
}
_ => panic!("Expected OrderResponse variant"),
}
}
#[rstest]
fn test_subscription_event_parsing() {
let subscription_json = r#"{
"event": "subscribe",
"arg": {
"channel": "tickers",
"instId": "BTC-USDT"
},
"connId": "a4d3ae55"
}"#;
let parsed: OKXWsFrame = serde_json::from_str(subscription_json).unwrap();
match parsed {
OKXWsFrame::Subscription {
event,
arg,
conn_id,
..
} => {
assert_eq!(
event,
crate::websocket::enums::OKXSubscriptionEvent::Subscribe
);
assert_eq!(arg.channel, OKXWsChannel::Tickers);
assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
assert_eq!(conn_id, "a4d3ae55");
}
_ => panic!("Expected Subscription variant"),
}
}
#[rstest]
fn test_login_event_parsing() {
let login_success = r#"{
"event": "login",
"code": "0",
"msg": "Login successful",
"connId": "a4d3ae55"
}"#;
let parsed: OKXWsFrame = serde_json::from_str(login_success).unwrap();
match parsed {
OKXWsFrame::Login {
event,
code,
msg,
conn_id,
} => {
assert_eq!(event, "login");
assert_eq!(code, "0");
assert_eq!(msg, "Login successful");
assert_eq!(conn_id, "a4d3ae55");
}
_ => panic!("Expected Login variant, was: {parsed:?}"),
}
}
#[rstest]
fn test_error_event_parsing() {
let error_json = r#"{
"code": "60012",
"msg": "Invalid request"
}"#;
let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
match parsed {
OKXWsFrame::Error { code, msg } => {
assert_eq!(code, "60012");
assert_eq!(msg, "Invalid request");
}
_ => panic!("Expected Error variant"),
}
}
#[rstest]
fn test_error_event_with_event_field_parsing() {
let error_json = r#"{
"event": "error",
"code": "60018",
"msg": "Invalid sign"
}"#;
let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
match parsed {
OKXWsFrame::Error { code, msg } => {
assert_eq!(code, "60018");
assert_eq!(msg, "Invalid sign");
}
_ => panic!("Expected Error variant, was: {parsed:?}"),
}
}
#[rstest]
fn test_subscription_error_with_arg_field_parsing() {
let error_json = r#"{
"event": "error",
"arg": {"channel": "tickers", "instId": "INVALID-INST"},
"code": "60012",
"msg": "Invalid request: channel not found",
"connId": "a4d3ae55"
}"#;
let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
match parsed {
OKXWsFrame::Error { code, msg } => {
assert_eq!(code, "60012");
assert_eq!(msg, "Invalid request: channel not found");
}
_ => panic!("Expected Error variant, was: {parsed:?}"),
}
}
#[rstest]
fn test_websocket_request_serialization() {
let request = OKXWsRequest {
id: Some("req-123".to_string()),
op: OKXWsOperation::Order,
args: vec![serde_json::json!({
"instId": "BTC-USDT",
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": "0.1"
})],
exp_time: None,
};
let serialized = serde_json::to_string(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed["id"], "req-123");
assert_eq!(parsed["op"], "order");
assert!(parsed["args"].is_array());
assert_eq!(parsed["args"].as_array().unwrap().len(), 1);
}
#[rstest]
fn test_subscription_request_serialization() {
let subscription = OKXSubscription {
op: OKXWsOperation::Subscribe,
args: vec![OKXSubscriptionArg {
channel: OKXWsChannel::Tickers,
inst_type: Some(OKXInstrumentType::Spot),
inst_family: None,
inst_id: Some(Ustr::from("BTC-USDT")),
}],
};
let serialized = serde_json::to_string(&subscription).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed["op"], "subscribe");
assert!(parsed["args"].is_array());
assert_eq!(parsed["args"][0]["channel"], "tickers");
assert_eq!(parsed["args"][0]["instType"], "SPOT");
assert_eq!(parsed["args"][0]["instId"], "BTC-USDT");
}
#[rstest]
fn test_error_message_extraction() {
let responses = vec![
(
r#"{
"id": "req-123",
"op": "order",
"code": "50001",
"msg": "Order failed",
"data": [{"sMsg": "Insufficient balance"}]
}"#,
"Insufficient balance",
),
(
r#"{
"id": "req-456",
"op": "cancel-order",
"code": "50002",
"msg": "Cancel failed",
"data": [{}]
}"#,
"Cancel failed",
),
];
for (response_json, expected_msg) in responses {
let parsed: OKXWsFrame = serde_json::from_str(response_json).unwrap();
match parsed {
OKXWsFrame::OrderResponse {
id: _,
op: _,
code,
msg,
data,
} => {
assert_ne!(code, "0");
let error_msg = data
.first()
.and_then(|d| d.get("sMsg"))
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(&msg);
assert_eq!(error_msg, expected_msg);
}
_ => panic!("Expected OrderResponse variant"),
}
}
}
#[rstest]
fn test_book_data_parsing() {
let book_data_json = r#"{
"arg": {
"channel": "books",
"instId": "BTC-USDT"
},
"action": "snapshot",
"data": [{
"asks": [["50000.0", "0.1", "0", "1"]],
"bids": [["49999.0", "0.2", "0", "1"]],
"ts": "1640995200000",
"checksum": 123456789,
"seqId": 1000
}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(book_data_json).unwrap();
match parsed {
OKXWsFrame::BookData { arg, action, data } => {
assert_eq!(arg.channel, OKXWsChannel::Books);
assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
assert_eq!(
action,
super::super::super::common::enums::OKXBookAction::Snapshot
);
assert_eq!(data.len(), 1);
}
_ => panic!("Expected BookData variant"),
}
}
#[rstest]
fn test_rpi_book_fixtures_preserve_depth_types_and_sequence() {
let snapshot: OKXWsFrame =
serde_json::from_str(&load_test_json("ws_books_rpi_snapshot.json")).unwrap();
let update: OKXWsFrame =
serde_json::from_str(&load_test_json("ws_books_rpi_update.json")).unwrap();
let OKXWsFrame::RpiBookData { arg, action, data } = snapshot else {
panic!("Expected RPI book snapshot");
};
let snapshot = &data[0];
assert_eq!(arg.channel, OKXWsChannel::BooksRpi);
assert_eq!(arg.inst_id, Some(Ustr::from("OMI-USD")));
assert_eq!(action, OKXBookAction::Snapshot);
assert_eq!(data.len(), 1);
assert_eq!(snapshot.asks.len(), 4);
assert_eq!(snapshot.bids.len(), 10);
assert_eq!(
snapshot.asks[0],
OKXRpiBookLevel(
Decimal::from_str_exact("0.0001617").unwrap(),
Decimal::from_str_exact("12325166.992").unwrap(),
Decimal::from(1000),
2,
)
);
assert_eq!(snapshot.prev_seq_id, -1);
assert_eq!(snapshot.seq_id, 1_082_831_226);
assert_eq!(snapshot.ts, 1_785_406_442_403);
let OKXWsFrame::RpiBookData { arg, action, data } = update else {
panic!("Expected RPI book update");
};
let update = &data[0];
assert_eq!(arg.channel, OKXWsChannel::BooksRpi);
assert_eq!(arg.inst_id, Some(Ustr::from("OMI-USD")));
assert_eq!(action, OKXBookAction::Update);
assert_eq!(data.len(), 1);
assert_eq!(update.asks.len(), 2);
assert!(update.bids.is_empty());
assert_eq!(
update.asks[1],
OKXRpiBookLevel(
Decimal::from_str_exact("0.0001625").unwrap(),
Decimal::from_str_exact("12324367.786").unwrap(),
Decimal::from(1000),
2,
)
);
assert_eq!(update.prev_seq_id, snapshot.seq_id as i64);
assert_eq!(update.seq_id, 1_082_831_230);
assert_eq!(update.ts, 1_785_406_443_903);
}
#[rstest]
fn test_rpi_book_rejects_checksum_field() {
let mut payload: serde_json::Value =
serde_json::from_str(&load_test_json("ws_books_rpi_update.json")).unwrap();
payload["data"][0]["checksum"] = serde_json::json!(0);
let error = serde_json::from_value::<OKXWsFrame>(payload).unwrap_err();
assert!(error.to_string().contains("checksum"));
}
#[rstest]
fn test_data_event_parsing() {
let data_json = r#"{
"arg": {
"channel": "trades",
"instId": "BTC-USDT"
},
"data": [{
"instId": "BTC-USDT",
"tradeId": "12345",
"px": "50000.0",
"sz": "0.1",
"side": "buy",
"ts": "1640995200000"
}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(data_json).unwrap();
match parsed {
OKXWsFrame::Data { arg, data } => {
assert_eq!(arg.channel, OKXWsChannel::Trades);
assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
assert!(data.is_array());
}
_ => panic!("Expected Data variant"),
}
}
#[rstest]
fn test_nautilus_message_variants() {
let clock = get_atomic_clock_realtime();
let ts_init = clock.get_time_ns();
let error = OKXWebSocketError {
code: "60012".to_string(),
message: "Invalid request".to_string(),
conn_id: None,
timestamp: ts_init.as_u64(),
};
let error_msg = NautilusWsMessage::Error(error);
match error_msg {
NautilusWsMessage::Error(e) => {
assert_eq!(e.code, "60012");
assert_eq!(e.message, "Invalid request");
}
_ => panic!("Expected Error variant"),
}
let raw_scenarios = vec![
::serde_json::json!({"unknown": "data"}),
::serde_json::json!({"channel": "unsupported", "data": [1, 2, 3]}),
::serde_json::json!({"complex": {"nested": {"structure": true}}}),
];
for raw_data in raw_scenarios {
let raw_msg = NautilusWsMessage::Raw(raw_data.clone());
match raw_msg {
NautilusWsMessage::Raw(data) => {
assert_eq!(data, raw_data);
}
_ => panic!("Expected Raw variant"),
}
}
}
#[rstest]
fn test_order_response_parsing_success() {
let order_response_json = r#"{
"id": "req-123",
"op": "order",
"code": "0",
"msg": "",
"data": [{"sMsg": "Order placed successfully"}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(order_response_json).unwrap();
match parsed {
OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
} => {
assert_eq!(id, Some("req-123".to_string()));
assert_eq!(op, OKXWsOperation::Order);
assert_eq!(code, "0");
assert_eq!(msg, "");
assert_eq!(data.len(), 1);
}
_ => panic!("Expected OrderResponse variant"),
}
}
#[rstest]
fn test_order_response_parsing_failure() {
let order_response_json = r#"{
"id": "req-456",
"op": "cancel-order",
"code": "50001",
"msg": "Order not found",
"data": [{"sMsg": "Order with client order ID not found"}]
}"#;
let parsed: OKXWsFrame = serde_json::from_str(order_response_json).unwrap();
match parsed {
OKXWsFrame::OrderResponse {
id,
op,
code,
msg,
data,
} => {
assert_eq!(id, Some("req-456".to_string()));
assert_eq!(op, OKXWsOperation::CancelOrder);
assert_eq!(code, "50001");
assert_eq!(msg, "Order not found");
assert_eq!(data.len(), 1);
}
_ => panic!("Expected OrderResponse variant"),
}
}
#[rstest]
fn test_message_request_serialization() {
let request = OKXWsRequest {
id: Some("req-123".to_string()),
op: OKXWsOperation::Order,
args: vec![::serde_json::json!({
"instId": "BTC-USDT",
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": "0.1"
})],
exp_time: None,
};
let serialized = serde_json::to_string(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed["id"], "req-123");
assert_eq!(parsed["op"], "order");
assert!(parsed["args"].is_array());
assert_eq!(parsed["args"].as_array().unwrap().len(), 1);
}
#[rstest]
fn test_ws_post_order_params_serializes_inst_id_code() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Limit)
.sz("0.01".to_string())
.px("50000".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"instIdCode\":10459"));
assert!(!json.contains("\"instId\""));
}
#[rstest]
fn test_ws_post_order_params_serializes_slippage_pct() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Market)
.sz("0.01".to_string())
.slippage_pct("0.005".to_string())
.build()
.unwrap();
let json: serde_json::Value = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["slippagePct"], "0.005");
}
#[rstest]
fn test_ws_post_order_params_omits_slippage_pct_when_unset() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Market)
.sz("0.01".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(!json.contains("slippagePct"));
}
#[rstest]
fn test_ws_post_order_params_serializes_attached_tp_sl() {
use super::{WsAttachAlgoOrdParamsBuilder, WsPostOrderParamsBuilder};
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode, OKXTriggerType};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Limit)
.sz("0.01".to_string())
.px("50000".to_string())
.attach_algo_ords(vec![
WsAttachAlgoOrdParamsBuilder::default()
.attach_algo_cl_ord_id("O-bracket-sl")
.sl_trigger_px("39000")
.sl_ord_px("-1")
.sl_trigger_px_type(OKXTriggerType::Last)
.build()
.unwrap(),
WsAttachAlgoOrdParamsBuilder::default()
.attach_algo_cl_ord_id("O-bracket-tp")
.tp_trigger_px("41000")
.tp_ord_px("-1")
.tp_trigger_px_type(OKXTriggerType::Last)
.build()
.unwrap(),
])
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"attachAlgoOrds\""));
assert!(json.contains("\"attachAlgoClOrdId\":\"O-bracket-sl\""));
assert!(json.contains("\"slTriggerPx\":\"39000\""));
assert!(json.contains("\"slOrdPx\":\"-1\""));
assert!(json.contains("\"attachAlgoClOrdId\":\"O-bracket-tp\""));
assert!(json.contains("\"tpTriggerPx\":\"41000\""));
assert!(json.contains("\"tpOrdPx\":\"-1\""));
}
#[rstest]
fn test_ws_cancel_order_params_serializes_inst_id_code() {
use super::WsCancelOrderParamsBuilder;
let params = WsCancelOrderParamsBuilder::default()
.inst_id_code(10461u64)
.ord_id("12345678".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"instIdCode\":10461"));
assert!(!json.contains("\"instId\""));
assert!(json.contains("\"ordId\":\"12345678\""));
}
#[rstest]
fn test_ws_amend_order_params_serializes_inst_id_code() {
use super::WsAmendOrderParamsBuilder;
let params = WsAmendOrderParamsBuilder::default()
.inst_id_code(10459u64)
.cl_ord_id("client123".to_string())
.new_px("51000".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"instIdCode\":10459"));
assert!(!json.contains("\"instId\""));
assert!(json.contains("\"newPx\":\"51000\""));
}
#[rstest]
fn test_ws_post_algo_order_params_serializes_inst_id_code() {
use super::WsPostAlgoOrderParamsBuilder;
use crate::common::enums::{OKXAlgoOrderType, OKXSide, OKXTradeMode, OKXTriggerType};
let params = WsPostAlgoOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXAlgoOrderType::Trigger)
.sz("0.01".to_string())
.trigger_px("48000".to_string())
.trigger_px_type(OKXTriggerType::Last)
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"instIdCode\":10459"));
assert!(!json.contains("\"instId\""));
assert!(json.contains("\"triggerPx\":\"48000\""));
}
#[rstest]
fn test_ws_cancel_algo_order_params_serializes_inst_id_code() {
let params = WsCancelAlgoOrderParams {
inst_id_code: 10459,
algo_id: Some("987654321".to_string()),
algo_cl_ord_id: None,
};
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"instIdCode\":10459"));
assert!(!json.contains("\"instId\""));
assert!(json.contains("\"algoId\":\"987654321\""));
}
#[rstest]
fn test_ws_post_order_params_serializes_px_usd() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Limit)
.sz("1".to_string())
.px_usd("100.5".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"pxUsd\":\"100.5\""));
assert!(!json.contains("\"pxVol\""));
assert!(!json.contains("\"px\":"));
}
#[rstest]
fn test_ws_post_order_params_serializes_px_vol() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cross)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Limit)
.sz("1".to_string())
.px_vol("0.55".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"pxVol\":\"0.55\""));
assert!(!json.contains("\"pxUsd\""));
assert!(!json.contains("\"px\":"));
}
#[rstest]
fn test_ws_amend_order_params_serializes_new_px_usd() {
use super::WsAmendOrderParamsBuilder;
let params = WsAmendOrderParamsBuilder::default()
.inst_id_code(10459u64)
.cl_ord_id("client123".to_string())
.new_px_usd("105.0".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"newPxUsd\":\"105.0\""));
assert!(!json.contains("\"newPx\":"));
assert!(!json.contains("\"newPxVol\""));
}
#[rstest]
fn test_ws_amend_order_params_serializes_new_px_vol() {
use super::WsAmendOrderParamsBuilder;
let params = WsAmendOrderParamsBuilder::default()
.inst_id_code(10459u64)
.cl_ord_id("client123".to_string())
.new_px_vol("0.60".to_string())
.build()
.unwrap();
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"newPxVol\":\"0.60\""));
assert!(!json.contains("\"newPx\":"));
assert!(!json.contains("\"newPxUsd\""));
}
#[rstest]
fn test_ws_event_contract_markets_channel_serialization() {
let json = serde_json::to_string(&OKXWsChannel::EventContractMarkets).unwrap();
let channel: OKXWsChannel = serde_json::from_str(&json).unwrap();
assert_eq!(json, "\"event-contract-markets\"");
assert_eq!(channel, OKXWsChannel::EventContractMarkets);
}
#[rstest]
fn test_ws_post_order_params_serializes_event_contract_fields() {
use super::WsPostOrderParamsBuilder;
use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
let params = WsPostOrderParamsBuilder::default()
.inst_id_code(10459u64)
.td_mode(OKXTradeMode::Cash)
.side(OKXSide::Buy)
.ord_type(OKXOrderType::Limit)
.sz("10".to_string())
.px("0.42".to_string())
.speed_bump("1")
.outcome("yes")
.build()
.unwrap();
let json: serde_json::Value = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["speedBump"], "1");
assert_eq!(json["outcome"], "yes");
}
#[rstest]
fn test_ws_amend_order_params_serializes_speed_bump() {
use super::WsAmendOrderParamsBuilder;
let params = WsAmendOrderParamsBuilder::default()
.inst_id_code(10459u64)
.cl_ord_id("event-1".to_string())
.new_px("0.43".to_string())
.speed_bump("1")
.build()
.unwrap();
let json: serde_json::Value = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["speedBump"], "1");
}
#[rstest]
fn test_ws_attach_algo_ord_params_serializes_trailing_fields() {
use super::WsAttachAlgoOrdParamsBuilder;
let params = WsAttachAlgoOrdParamsBuilder::default()
.attach_algo_cl_ord_id("trail-1")
.callback_ratio("0.01")
.active_px("64000")
.new_callback_ratio("0.02")
.new_callback_spread("25")
.new_active_px("65000")
.build()
.unwrap();
let json: serde_json::Value = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["callbackRatio"], "0.01");
assert_eq!(json["activePx"], "64000");
assert_eq!(json["newCallbackRatio"], "0.02");
assert_eq!(json["newCallbackSpread"], "25");
assert_eq!(json["newActivePx"], "65000");
assert!(json.get("callbackSpread").is_none());
}
#[rstest]
fn test_subscription_arg_serializes_sprd_id_for_spread_channels() {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::SprdBooks5,
inst_type: None,
inst_family: None,
inst_id: Some(Ustr::from("ETH-USD-260925_ETH-USD-261225")),
};
let json = serde_json::to_value(&arg).unwrap();
assert_eq!(json["channel"], "sprd-books5");
assert_eq!(json["sprdId"], "ETH-USD-260925_ETH-USD-261225");
assert!(json.get("instId").is_none());
}
#[rstest]
fn test_subscription_arg_serializes_inst_id_for_standard_channels() {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::BboTbt,
inst_type: None,
inst_family: None,
inst_id: Some(Ustr::from("BTC-USDT")),
};
let json = serde_json::to_value(&arg).unwrap();
assert_eq!(json["instId"], "BTC-USDT");
assert!(json.get("sprdId").is_none());
}
#[rstest]
fn test_websocket_arg_resolves_sprd_id_into_inst_id() {
let arg: OKXWebSocketArg = serde_json::from_value(serde_json::json!({
"channel": "sprd-bbo-tbt",
"sprdId": "ETH-USD-260925_ETH-USD-261225",
}))
.unwrap();
assert_eq!(arg.channel, OKXWsChannel::SprdBboTbt);
assert_eq!(
arg.inst_id,
Some(Ustr::from("ETH-USD-260925_ETH-USD-261225"))
);
}
#[rstest]
fn test_book_msg_parses_three_element_spread_levels() {
let msg: OKXBookMsg = serde_json::from_value(serde_json::json!({
"asks": [["16.7", "100", "1"]],
"bids": [["16.65", "100", "1"]],
"ts": "1780044924909",
"seqId": 1779935772619784_u64,
}))
.unwrap();
assert_eq!(msg.asks[0].price, "16.7");
assert_eq!(msg.asks[0].size, "100");
assert_eq!(msg.bids[0].price, "16.65");
}
#[rstest]
fn test_trade_msg_parses_spread_public_trade() {
let msg: OKXTradeMsg = serde_json::from_value(serde_json::json!({
"sprdId": "ETH-USD-260925_ETH-USD-261225",
"tradeId": "3392538740127301632",
"px": "16.9",
"sz": "100",
"side": "sell",
"ts": "1780047866507",
}))
.unwrap();
assert_eq!(msg.inst_id, Ustr::from("ETH-USD-260925_ETH-USD-261225"));
assert_eq!(msg.px, "16.9");
assert_eq!(msg.side, OKXSide::Sell);
assert!(msg.count.is_empty());
}
}