use std::{
fmt::Debug,
num::NonZeroU32,
sync::{
Arc, LazyLock,
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
},
time::{Duration, SystemTime},
};
use ahash::{AHashMap, AHashSet};
use arc_swap::ArcSwap;
use dashmap::DashMap;
use futures_util::Stream;
use nautilus_common::live::get_runtime;
use nautilus_core::{
AtomicMap,
consts::NAUTILUS_USER_AGENT,
env::{get_env_var, get_or_env_var},
string::secret::REDACTED,
};
use nautilus_model::{
data::BarType,
enums::{OrderSide, OrderType, PositionSide, TimeInForce, TriggerType},
identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
instruments::{Instrument, InstrumentAny},
types::{Price, Quantity},
};
use nautilus_network::{
http::USER_AGENT,
mode::ConnectionMode,
ratelimiter::quota::Quota,
websocket::{
AUTHENTICATION_TIMEOUT_SECS, AuthTracker, PingHandler, SubscriptionState, TEXT_PING,
TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
},
};
use serde_json::Value;
use tokio_tungstenite::tungstenite::Error;
use tokio_util::sync::CancellationToken;
use ustr::Ustr;
use super::{
enums::OKXWsChannel,
error::OKXWsError,
handler::{HandlerCommand, OKXWsFeedHandler},
messages::{
OKXAuthentication, OKXAuthenticationArg, OKXSubscriptionArg, OKXWsMessage, OKXWsRequest,
WsAmendOrderParamsBuilder, WsAttachAlgoOrdParams, WsCancelOrderParamsBuilder,
WsMassCancelParams, WsPostAlgoOrderParamsBuilder, WsPostOrderParamsBuilder,
},
subscription::topic_from_subscription_arg,
};
use crate::common::{
consts::{
OKX_NAUTILUS_BROKER_ID, OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
OKX_WS_PUBLIC_URL, OKX_WS_TOPIC_DELIMITER, select_book_channel,
},
credential::Credential,
enums::{
OKXBookChannel, OKXGreeksType, OKXInstrumentType, OKXOrderType, OKXPositionSide,
OKXTargetCurrency, OKXTradeMode, OKXTriggerType, OKXVipLevel,
conditional_order_to_algo_type, is_conditional_order,
},
parse::{
bar_spec_as_okx_channel, okx_instrument_type, okx_instrument_type_from_symbol,
parse_base_quote_from_symbol,
},
};
pub static OKX_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant")
});
pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).expect("non-zero")));
pub static OKX_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
});
pub static OKX_WS_BATCH_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant")
});
pub static OKX_WS_MASS_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
});
pub static OKX_WS_ALGO_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
});
pub static OKX_WS_ALGO_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
});
pub static OKX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("subscription")]);
pub static OKX_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("order")]);
pub static OKX_RATE_LIMIT_KEY_BATCH_ORDER: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("batch-order")]);
pub static OKX_RATE_LIMIT_KEY_CANCEL: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("cancel")]);
pub static OKX_RATE_LIMIT_KEY_BATCH_CANCEL: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("batch-cancel")]);
pub static OKX_RATE_LIMIT_KEY_MASS_CANCEL: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("mass-cancel")]);
pub static OKX_RATE_LIMIT_KEY_AMEND: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("amend")]);
pub static OKX_RATE_LIMIT_KEY_BATCH_AMEND: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("batch-amend")]);
pub static OKX_RATE_LIMIT_KEY_ALGO_ORDER: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("algo-order")]);
pub static OKX_RATE_LIMIT_KEY_ALGO_CANCEL: LazyLock<[Ustr; 1]> =
LazyLock::new(|| [Ustr::from("algo-cancel")]);
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct PendingOrderInfo {
pub trader_id: TraderId,
pub strategy_id: StrategyId,
pub instrument_id: InstrumentId,
}
#[derive(Clone)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.okx", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
)]
pub struct OKXWebSocketClient {
url: String,
#[allow(dead_code)] pub(crate) account_id: AccountId,
vip_level: Arc<AtomicU8>,
credential: Option<Credential>,
heartbeat: Option<u64>,
auth_timeout_secs: u64,
auth_tracker: AuthTracker,
signal: Arc<AtomicBool>,
connection_mode: Arc<ArcSwap<AtomicU8>>,
cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<OKXWsMessage>>>,
task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
subscriptions_inst_type: Arc<DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>>,
subscriptions_inst_family: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
subscriptions_inst_id: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
subscriptions_bare: Arc<DashMap<OKXWsChannel, bool>>,
subscriptions_state: SubscriptionState,
request_id_counter: Arc<AtomicU64>,
instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
inst_id_code_cache: Arc<AtomicMap<Ustr, u64>>,
pub(crate) pending_orders: Arc<DashMap<String, PendingOrderInfo>>,
pub(crate) pending_cancels: Arc<DashMap<String, PendingOrderInfo>>,
pub(crate) pending_amends: Arc<DashMap<String, PendingOrderInfo>>,
option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
index_pair_subscribers: Arc<DashMap<Ustr, usize>>,
index_pair_transition: Arc<tokio::sync::Mutex<()>>,
transport_backend: TransportBackend,
proxy_url: Option<String>,
cancellation_token: CancellationToken,
}
impl Default for OKXWebSocketClient {
fn default() -> Self {
Self::new(
None,
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.unwrap()
}
}
impl Debug for OKXWebSocketClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(OKXWebSocketClient))
.field("url", &self.url)
.field("credential", &self.credential.as_ref().map(|_| REDACTED))
.field("heartbeat", &self.heartbeat)
.finish_non_exhaustive()
}
}
impl OKXWebSocketClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
url: Option<String>,
api_key: Option<String>,
api_secret: Option<String>,
api_passphrase: Option<String>,
account_id: Option<AccountId>,
heartbeat: Option<u64>,
auth_timeout_secs: Option<u64>,
transport_backend: TransportBackend,
proxy_url: Option<String>,
) -> anyhow::Result<Self> {
let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
let account_id = account_id.unwrap_or(AccountId::from("OKX-master"));
let credential = match (api_key, api_secret, api_passphrase) {
(Some(key), Some(secret), Some(passphrase)) => {
Some(Credential::new(key, secret, passphrase))
}
(None, None, None) => None,
_ => anyhow::bail!(
"`api_key`, `api_secret`, `api_passphrase` credentials must be provided together"
),
};
let signal = Arc::new(AtomicBool::new(false));
let subscriptions_inst_type = Arc::new(DashMap::new());
let subscriptions_inst_family = Arc::new(DashMap::new());
let subscriptions_inst_id = Arc::new(DashMap::new());
let subscriptions_bare = Arc::new(DashMap::new());
let subscriptions_state = SubscriptionState::new(OKX_WS_TOPIC_DELIMITER);
Ok(Self {
url,
account_id,
vip_level: Arc::new(AtomicU8::new(0)),
credential,
heartbeat,
auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
auth_tracker: AuthTracker::new(),
signal,
connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
ConnectionMode::Closed.as_u8(),
))),
cmd_tx: {
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
Arc::new(tokio::sync::RwLock::new(tx))
},
out_rx: None,
task_handle: None,
subscriptions_inst_type,
subscriptions_inst_family,
subscriptions_inst_id,
subscriptions_bare,
subscriptions_state,
request_id_counter: Arc::new(AtomicU64::new(1)),
instruments_cache: Arc::new(AtomicMap::new()),
inst_id_code_cache: Arc::new(AtomicMap::new()),
pending_orders: Arc::new(DashMap::new()),
pending_cancels: Arc::new(DashMap::new()),
pending_amends: Arc::new(DashMap::new()),
option_greeks_subs: Arc::new(AtomicMap::new()),
index_pair_subscribers: Arc::new(DashMap::new()),
index_pair_transition: Arc::new(tokio::sync::Mutex::new(())),
transport_backend,
proxy_url,
cancellation_token: CancellationToken::new(),
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_credentials(
url: Option<String>,
api_key: Option<String>,
api_secret: Option<String>,
api_passphrase: Option<String>,
account_id: Option<AccountId>,
heartbeat: Option<u64>,
auth_timeout_secs: Option<u64>,
transport_backend: TransportBackend,
proxy_url: Option<String>,
) -> anyhow::Result<Self> {
let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
Self::new(
Some(url),
Some(api_key),
Some(api_secret),
Some(api_passphrase),
account_id,
heartbeat,
auth_timeout_secs,
transport_backend,
proxy_url,
)
}
pub fn from_env() -> anyhow::Result<Self> {
let url = get_env_var("OKX_WS_URL")?;
let api_key = get_env_var("OKX_API_KEY")?;
let api_secret = get_env_var("OKX_API_SECRET")?;
let api_passphrase = get_env_var("OKX_API_PASSPHRASE")?;
Self::new(
Some(url),
Some(api_key),
Some(api_secret),
Some(api_passphrase),
None,
None,
None,
TransportBackend::default(),
None,
)
}
pub fn cancel_all_requests(&self) {
self.cancellation_token.cancel();
}
pub fn cancellation_token(&self) -> &CancellationToken {
&self.cancellation_token
}
pub fn url(&self) -> &str {
self.url.as_str()
}
pub fn api_key(&self) -> Option<&str> {
self.credential.as_ref().map(|c| c.api_key())
}
#[must_use]
pub fn api_key_masked(&self) -> Option<String> {
self.credential.as_ref().map(|c| c.api_key_masked())
}
pub fn is_active(&self) -> bool {
let connection_mode_arc = self.connection_mode.load();
ConnectionMode::from_atomic(&connection_mode_arc).is_active()
&& !self.signal.load(Ordering::Acquire)
}
pub fn is_closed(&self) -> bool {
let connection_mode_arc = self.connection_mode.load();
ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
|| self.signal.load(Ordering::Acquire)
}
pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
self.instruments_cache.rcu(|m| {
for inst in instruments {
m.insert(inst.symbol().inner(), inst.clone());
}
});
}
pub fn cache_instrument(&self, instrument: InstrumentAny) {
self.instruments_cache
.insert(instrument.symbol().inner(), instrument);
}
pub fn instruments_snapshot(&self) -> AHashMap<Ustr, InstrumentAny> {
(**self.instruments_cache.load()).clone()
}
pub fn instruments_cache_arc(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
Arc::clone(&self.instruments_cache)
}
pub fn cache_inst_id_code(&self, inst_id: Ustr, inst_id_code: u64) {
self.inst_id_code_cache.insert(inst_id, inst_id_code);
}
pub fn cache_inst_id_codes(&self, mappings: impl IntoIterator<Item = (Ustr, u64)>) {
let entries: Vec<_> = mappings.into_iter().collect();
self.inst_id_code_cache.rcu(|m| {
for (inst_id, inst_id_code) in &entries {
m.insert(*inst_id, *inst_id_code);
}
});
}
#[must_use]
pub fn get_inst_id_code(&self, inst_id: &Ustr) -> Option<u64> {
self.inst_id_code_cache.load().get(inst_id).copied()
}
fn inst_id_symbol_and_code_from_snapshot(
inst_id_codes: &AHashMap<Ustr, u64>,
inst_id: &InstrumentId,
action: &str,
) -> Result<(Ustr, u64), OKXWsError> {
let inst_id_symbol = inst_id.symbol.inner();
let inst_id_code = inst_id_codes.get(&inst_id_symbol).copied().ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {inst_id}, cannot {action} order"
))
})?;
Ok((inst_id_symbol, inst_id_code))
}
pub fn set_vip_level(&self, vip_level: OKXVipLevel) {
self.vip_level.store(vip_level as u8, Ordering::Relaxed);
}
pub fn vip_level(&self) -> OKXVipLevel {
let level = self.vip_level.load(Ordering::Relaxed);
OKXVipLevel::from(level)
}
pub async fn connect(&mut self) -> anyhow::Result<()> {
self.signal.store(false, Ordering::Release);
let (message_handler, raw_rx) = channel_message_handler();
let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {
});
let headers = vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())];
let config = WebSocketConfig {
url: self.url.clone(),
headers,
heartbeat: self.heartbeat,
heartbeat_msg: Some(TEXT_PING.to_string()),
reconnect_timeout_ms: Some(5_000),
reconnect_delay_initial_ms: None,
reconnect_delay_max_ms: None,
reconnect_backoff_factor: None,
reconnect_jitter_ms: None,
reconnect_max_attempts: None,
idle_timeout_ms: None,
backend: self.transport_backend,
proxy_url: self.proxy_url.clone(),
};
let keyed_quotas = vec![
(
OKX_RATE_LIMIT_KEY_SUBSCRIPTION[0].as_str().to_string(),
*OKX_WS_SUBSCRIPTION_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_ORDER[0].as_str().to_string(),
*OKX_WS_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_BATCH_ORDER[0].as_str().to_string(),
*OKX_WS_BATCH_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_CANCEL[0].as_str().to_string(),
*OKX_WS_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_BATCH_CANCEL[0].as_str().to_string(),
*OKX_WS_BATCH_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_MASS_CANCEL[0].as_str().to_string(),
*OKX_WS_MASS_CANCEL_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_AMEND[0].as_str().to_string(),
*OKX_WS_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_BATCH_AMEND[0].as_str().to_string(),
*OKX_WS_BATCH_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_ALGO_ORDER[0].as_str().to_string(),
*OKX_WS_ALGO_ORDER_QUOTA,
),
(
OKX_RATE_LIMIT_KEY_ALGO_CANCEL[0].as_str().to_string(),
*OKX_WS_ALGO_CANCEL_QUOTA,
),
];
let client = WebSocketClient::connect(
config,
Some(message_handler),
Some(ping_handler),
None, keyed_quotas,
Some(*OKX_WS_CONNECTION_QUOTA), )
.await?;
self.connection_mode.store(client.connection_mode_atomic());
let (msg_tx, rx) = tokio::sync::mpsc::unbounded_channel::<OKXWsMessage>();
self.out_rx = Some(Arc::new(rx));
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
*self.cmd_tx.write().await = cmd_tx.clone();
let signal = self.signal.clone();
let auth_tracker = self.auth_tracker.clone();
let subscriptions_state = self.subscriptions_state.clone();
let stream_handle = get_runtime().spawn({
let auth_tracker = auth_tracker.clone();
let signal = signal.clone();
let credential = self.credential.clone();
let cmd_tx_for_reconnect = cmd_tx.clone();
let subscriptions_bare = self.subscriptions_bare.clone();
let subscriptions_inst_type = self.subscriptions_inst_type.clone();
let subscriptions_inst_family = self.subscriptions_inst_family.clone();
let subscriptions_inst_id = self.subscriptions_inst_id.clone();
let mut has_reconnected = false;
async move {
let mut handler = OKXWsFeedHandler::new(
signal.clone(),
cmd_rx,
raw_rx,
msg_tx,
auth_tracker.clone(),
subscriptions_state.clone(),
);
let resubscribe_all = || {
for entry in subscriptions_inst_id.iter() {
let (channel, inst_ids) = entry.pair();
for inst_id in inst_ids {
let arg = OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: None,
inst_id: Some(*inst_id),
};
if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
log::error!("Failed to send resubscribe command: error={e}");
}
}
}
for entry in subscriptions_bare.iter() {
let channel = entry.key();
let arg = OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: None,
inst_id: None,
};
if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
log::error!("Failed to send resubscribe command: error={e}");
}
}
for entry in subscriptions_inst_type.iter() {
let (channel, inst_types) = entry.pair();
for inst_type in inst_types {
let arg = OKXSubscriptionArg {
channel: channel.clone(),
inst_type: Some(*inst_type),
inst_family: None,
inst_id: None,
};
if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
log::error!("Failed to send resubscribe command: error={e}");
}
}
}
for entry in subscriptions_inst_family.iter() {
let (channel, inst_families) = entry.pair();
for inst_family in inst_families {
let arg = OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: Some(*inst_family),
inst_id: None,
};
if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
log::error!("Failed to send resubscribe command: error={e}");
}
}
}
};
loop {
match handler.next().await {
Some(OKXWsMessage::Reconnected) => {
if signal.load(Ordering::Acquire) {
continue;
}
has_reconnected = true;
let confirmed_topics_vec: Vec<String> = {
let confirmed = subscriptions_state.confirmed();
let mut topics = Vec::new();
for entry in confirmed.iter() {
let channel = entry.key();
for symbol in entry.value() {
if symbol.as_str() == "#" {
topics.push(channel.to_string());
} else {
topics.push(format!("{channel}{OKX_WS_TOPIC_DELIMITER}{symbol}"));
}
}
}
topics
};
if !confirmed_topics_vec.is_empty() {
log::debug!("Marking confirmed subscriptions as pending for replay: count={}", confirmed_topics_vec.len());
for topic in confirmed_topics_vec {
subscriptions_state.mark_failure(&topic);
}
}
if let Some(cred) = &credential {
log::debug!("Re-authenticating after reconnection");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.expect("System time should be after UNIX epoch")
.as_secs()
.to_string();
let signature = cred.sign(×tamp, "GET", "/users/self/verify", "");
let auth_message = super::messages::OKXAuthentication {
op: "login",
args: vec![super::messages::OKXAuthenticationArg {
api_key: cred.api_key().to_string(),
passphrase: cred.api_passphrase().to_string(),
timestamp,
sign: signature,
}],
};
if let Ok(payload) = serde_json::to_string(&auth_message) {
if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Authenticate { payload }) {
log::error!("Failed to send reconnection auth command: error={e}");
}
} else {
log::error!("Failed to serialize reconnection auth message");
}
}
if credential.is_none() {
log::debug!("No authentication required, resubscribing immediately");
resubscribe_all();
}
if handler.send(OKXWsMessage::Reconnected).is_err() {
log_receiver_dropped(&signal, "Reconnected");
break;
}
}
Some(OKXWsMessage::Authenticated) => {
if has_reconnected {
resubscribe_all();
}
}
Some(msg) => {
if handler.send(msg).is_err() {
log_receiver_dropped(&signal, "message");
break;
}
}
None => {
if handler.is_stopped() {
log::debug!(
"Stop signal received, ending message processing",
);
break;
}
log::debug!("WebSocket stream closed");
break;
}
}
}
log::debug!("Handler task exiting");
}
});
self.task_handle = Some(Arc::new(stream_handle));
self.cmd_tx
.read()
.await
.send(HandlerCommand::SetClient(client))
.map_err(|e| {
OKXWsError::ClientError(format!("Failed to send WebSocket client to handler: {e}"))
})?;
log::debug!("Sent WebSocket client to handler");
if self.credential.is_some()
&& let Err(e) = self.authenticate().await
{
anyhow::bail!("Authentication failed: {e}");
}
Ok(())
}
async fn authenticate(&self) -> Result<(), Error> {
let credential = self.credential.as_ref().ok_or_else(|| {
Error::Io(std::io::Error::other(
"API credentials not available to authenticate",
))
})?;
let rx = self.auth_tracker.begin();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("System time should be after UNIX epoch")
.as_secs()
.to_string();
let signature = credential.sign(×tamp, "GET", "/users/self/verify", "");
let auth_message = OKXAuthentication {
op: "login",
args: vec![OKXAuthenticationArg {
api_key: credential.api_key().to_string(),
passphrase: credential.api_passphrase().to_string(),
timestamp,
sign: signature,
}],
};
let payload = serde_json::to_string(&auth_message).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"Failed to serialize auth message: {e}"
)))
})?;
self.cmd_tx
.read()
.await
.send(HandlerCommand::Authenticate { payload })
.map_err(|e| {
Error::Io(std::io::Error::other(format!(
"Failed to send authenticate command: {e}"
)))
})?;
match self
.auth_tracker
.wait_for_result::<OKXWsError>(Duration::from_secs(self.auth_timeout_secs), rx)
.await
{
Ok(()) => {
log::debug!("WebSocket authenticated");
Ok(())
}
Err(e) => {
log::error!("WebSocket authentication failed: error={e}");
Err(Error::Io(std::io::Error::other(e.to_string())))
}
}
}
pub fn stream(&mut self) -> impl Stream<Item = OKXWsMessage> + 'static {
let rx = self
.out_rx
.take()
.expect("Data stream receiver already taken or not connected");
let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
async_stream::stream! {
while let Some(data) = rx.recv().await {
yield data;
}
}
}
pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), OKXWsError> {
let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
tokio::time::timeout(timeout, async {
while !self.is_active() {
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
})
.await
.map_err(|_| {
OKXWsError::ClientError(format!(
"WebSocket connection timeout after {timeout_secs} seconds"
))
})?;
Ok(())
}
pub async fn close(&mut self) -> Result<(), Error> {
log::debug!("Starting close process");
self.signal.store(true, Ordering::Release);
if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
log::debug!("Handler channel closed before disconnect command was sent: {e}");
} else {
log::debug!("Sent disconnect command to handler");
}
if let Some(stream_handle) = self.task_handle.take() {
match Arc::try_unwrap(stream_handle) {
Ok(handle) => {
log::debug!("Waiting for stream handle to complete");
let abort_handle = handle.abort_handle();
match tokio::time::timeout(Duration::from_secs(2), handle).await {
Ok(Ok(())) => log::debug!("Stream handle completed successfully"),
Ok(Err(e)) => log::error!("Stream handle encountered an error: {e:?}"),
Err(_) => {
log::warn!("Timeout waiting for stream handle, aborting task");
abort_handle.abort();
}
}
}
Err(arc_handle) => {
log::debug!(
"Cannot take ownership of stream handle - other references exist, aborting task"
);
arc_handle.abort();
}
}
} else {
log::debug!("No stream handle to await");
}
self.index_pair_subscribers.clear();
log::debug!("Close process completed");
Ok(())
}
pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<OKXWsChannel> {
let symbol = instrument_id.symbol.inner();
let mut channels = Vec::new();
for entry in self.subscriptions_inst_id.iter() {
let (channel, instruments) = entry.pair();
if instruments.contains(&symbol) {
channels.push(channel.clone());
}
}
channels
}
fn generate_unique_request_id(&self) -> String {
self.request_id_counter
.fetch_add(1, Ordering::SeqCst)
.to_string()
}
async fn subscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
self.cmd_tx
.read()
.await
.send(HandlerCommand::Subscribe { args: args.clone() })
.map_err(|e| {
OKXWsError::ClientError(format!("Failed to send subscribe command: {e}"))
})?;
for arg in &args {
let topic = topic_from_subscription_arg(arg);
self.subscriptions_state.mark_subscribe(&topic);
if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
self.subscriptions_bare.insert(arg.channel.clone(), true);
} else {
if let Some(inst_type) = &arg.inst_type {
self.subscriptions_inst_type
.entry(arg.channel.clone())
.or_default()
.insert(*inst_type);
}
if let Some(inst_family) = &arg.inst_family {
self.subscriptions_inst_family
.entry(arg.channel.clone())
.or_default()
.insert(*inst_family);
}
if let Some(inst_id) = &arg.inst_id {
self.subscriptions_inst_id
.entry(arg.channel.clone())
.or_default()
.insert(*inst_id);
}
}
}
Ok(())
}
#[expect(clippy::collapsible_if)]
async fn unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
self.cmd_tx
.read()
.await
.send(HandlerCommand::Unsubscribe { args: args.clone() })
.map_err(|e| {
OKXWsError::ClientError(format!("Failed to send unsubscribe command: {e}"))
})?;
for arg in &args {
let topic = topic_from_subscription_arg(arg);
self.subscriptions_state.mark_unsubscribe(&topic);
if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
self.subscriptions_bare.remove(&arg.channel);
} else {
if let Some(inst_type) = &arg.inst_type {
if let Some(mut entry) = self.subscriptions_inst_type.get_mut(&arg.channel) {
entry.remove(inst_type);
if entry.is_empty() {
drop(entry);
self.subscriptions_inst_type.remove(&arg.channel);
}
}
}
if let Some(inst_family) = &arg.inst_family {
if let Some(mut entry) = self.subscriptions_inst_family.get_mut(&arg.channel) {
entry.remove(inst_family);
if entry.is_empty() {
drop(entry);
self.subscriptions_inst_family.remove(&arg.channel);
}
}
}
if let Some(inst_id) = &arg.inst_id {
if let Some(mut entry) = self.subscriptions_inst_id.get_mut(&arg.channel) {
entry.remove(inst_id);
if entry.is_empty() {
drop(entry);
self.subscriptions_inst_id.remove(&arg.channel);
}
}
}
}
}
Ok(())
}
async fn subscribe_inst_id(
&self,
channel: OKXWsChannel,
inst_id: Ustr,
) -> Result<(), OKXWsError> {
self.subscribe(vec![OKXSubscriptionArg {
channel,
inst_type: None,
inst_family: None,
inst_id: Some(inst_id),
}])
.await
}
async fn unsubscribe_inst_id(
&self,
channel: OKXWsChannel,
inst_id: Ustr,
) -> Result<(), OKXWsError> {
self.unsubscribe(vec![OKXSubscriptionArg {
channel,
inst_type: None,
inst_family: None,
inst_id: Some(inst_id),
}])
.await
}
pub async fn unsubscribe_all(&self) -> Result<(), OKXWsError> {
const BATCH_SIZE: usize = 256;
let mut all_args = Vec::new();
for entry in self.subscriptions_inst_type.iter() {
let (channel, inst_types) = entry.pair();
for inst_type in inst_types {
all_args.push(OKXSubscriptionArg {
channel: channel.clone(),
inst_type: Some(*inst_type),
inst_family: None,
inst_id: None,
});
}
}
for entry in self.subscriptions_inst_family.iter() {
let (channel, inst_families) = entry.pair();
for inst_family in inst_families {
all_args.push(OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: Some(*inst_family),
inst_id: None,
});
}
}
for entry in self.subscriptions_inst_id.iter() {
let (channel, inst_ids) = entry.pair();
for inst_id in inst_ids {
all_args.push(OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: None,
inst_id: Some(*inst_id),
});
}
}
for entry in self.subscriptions_bare.iter() {
let channel = entry.key();
all_args.push(OKXSubscriptionArg {
channel: channel.clone(),
inst_type: None,
inst_family: None,
inst_id: None,
});
}
if all_args.is_empty() {
log::debug!("No active subscriptions to unsubscribe from");
return Ok(());
}
log::debug!("Batched unsubscribe from {} channels", all_args.len());
for chunk in all_args.chunks(BATCH_SIZE) {
self.unsubscribe(chunk.to_vec()).await?;
}
self.index_pair_subscribers.clear();
Ok(())
}
pub async fn subscribe_instruments(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Instruments,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn subscribe_instrument(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
let inst_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
log::debug!("Subscribing to instrument type {inst_type:?} for {instrument_id}");
self.subscribe_instruments(inst_type).await
}
pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
self.subscribe_book_with_depth(instrument_id, 0).await
}
pub(crate) async fn subscribe_books_channel(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_book_rpi(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
.await
}
pub(crate) async fn resubscribe_book_channel(
&self,
instrument_id: InstrumentId,
channel: OKXBookChannel,
) -> Result<(), OKXWsError> {
let channel = ws_channel_for_book(channel);
self.resubscribe_ws_channel(instrument_id, channel).await
}
pub(crate) async fn resubscribe_ws_channel(
&self,
instrument_id: InstrumentId,
channel: OKXWsChannel,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(channel.clone(), instrument_id.symbol.inner())
.await?;
self.subscribe_inst_id(channel, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_book_depth5(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_book50_l2_tbt(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_book_l2_tbt(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_book_with_depth(
&self,
instrument_id: InstrumentId,
depth: u16,
) -> anyhow::Result<()> {
let vip = self.vip_level();
if !matches!(depth, 0 | 50 | 400) {
anyhow::bail!("Invalid depth {depth}, must be 0, 50, or 400");
}
if depth == 50 && vip < OKXVipLevel::Vip4 {
anyhow::bail!("VIP level {vip} insufficient for 50 depth subscription (requires VIP4)");
}
let channel = select_book_channel(depth as usize, vip);
self.subscribe_inst_id(ws_channel_for_book(channel), instrument_id.symbol.inner())
.await?;
Ok(())
}
pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_trades(
&self,
instrument_id: InstrumentId,
aggregated: bool,
) -> Result<(), OKXWsError> {
let channel = if aggregated {
OKXWsChannel::TradesAll
} else {
OKXWsChannel::Trades
};
self.subscribe_inst_id(channel, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_mark_prices(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_index_prices(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
let symbol = instrument_id.symbol.inner();
let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
let base_pair = Ustr::from(&format!("{base}-{quote}"));
let _guard = self.index_pair_transition.lock().await;
let is_first = {
let mut count = self.index_pair_subscribers.entry(base_pair).or_insert(0);
*count += 1;
*count == 1
};
if !is_first {
return Ok(());
}
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::IndexTickers,
inst_type: None,
inst_family: None,
inst_id: Some(base_pair),
};
match self.subscribe(vec![arg]).await {
Ok(()) => Ok(()),
Err(e) => {
self.index_pair_subscribers.remove(&base_pair);
Err(e)
}
}
}
pub async fn subscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::OptionSummary,
inst_type: None,
inst_family: Some(inst_family),
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn subscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::EventContractMarkets,
inst_type: Some(OKXInstrumentType::Events),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub fn option_greeks_subs(&self) -> &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>> {
&self.option_greeks_subs
}
pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
let both: AHashSet<OKXGreeksType> =
[OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
self.option_greeks_subs.insert(instrument_id, both);
}
pub fn add_option_greeks_sub_with_conventions(
&self,
instrument_id: InstrumentId,
conventions: AHashSet<OKXGreeksType>,
) {
let set = if conventions.is_empty() {
[OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
} else {
conventions
};
self.option_greeks_subs.insert(instrument_id, set);
}
pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
self.option_greeks_subs.remove(instrument_id);
}
pub async fn subscribe_funding_rates(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
let channel = bar_spec_as_okx_channel(bar_type.spec())
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
self.subscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
.await
}
pub async fn unsubscribe_instruments(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Instruments,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn unsubscribe_instrument(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
log::debug!("Instrument unsubscribe is a no-op (shared per-type channel): {instrument_id}");
Ok(())
}
pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_book_rpi(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_book_depth5(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_book50_l2_tbt(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_book_l2_tbt(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_mark_prices(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_index_prices(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
let symbol = instrument_id.symbol.inner();
let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
let base_pair = Ustr::from(&format!("{base}-{quote}"));
let _guard = self.index_pair_transition.lock().await;
let is_last = {
let Some(mut count) = self.index_pair_subscribers.get_mut(&base_pair) else {
return Ok(());
};
*count = count.saturating_sub(1);
*count == 0
};
if !is_last {
return Ok(());
}
self.index_pair_subscribers
.remove_if(&base_pair, |_, count| *count == 0);
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::IndexTickers,
inst_type: None,
inst_family: None,
inst_id: Some(base_pair),
};
self.unsubscribe(vec![arg]).await
}
pub async fn unsubscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::OptionSummary,
inst_type: None,
inst_family: Some(inst_family),
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn unsubscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::EventContractMarkets,
inst_type: Some(OKXInstrumentType::Events),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn unsubscribe_funding_rates(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_trades(
&self,
instrument_id: InstrumentId,
aggregated: bool,
) -> Result<(), OKXWsError> {
let channel = if aggregated {
OKXWsChannel::TradesAll
} else {
OKXWsChannel::Trades
};
self.unsubscribe_inst_id(channel, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
let channel = bar_spec_as_okx_channel(bar_type.spec())
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
self.unsubscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
.await
}
pub async fn subscribe_orders(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Orders,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_orders(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Orders,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_spread_orders(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::SprdOrders,
inst_type: None,
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_spread_orders(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::SprdOrders,
inst_type: None,
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_spread_quotes(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_spread_book(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_spread_trades(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.subscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_spread_quotes(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_spread_book(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
.await
}
pub async fn unsubscribe_spread_trades(
&self,
instrument_id: InstrumentId,
) -> Result<(), OKXWsError> {
self.unsubscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
.await
}
pub async fn subscribe_orders_algo(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::OrdersAlgo,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_orders_algo(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::OrdersAlgo,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_algo_advance(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::AlgoAdvance,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_algo_advance(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::AlgoAdvance,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_fills(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Fills,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_fills(
&self,
instrument_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Fills,
inst_type: Some(instrument_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_account(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Account,
inst_type: None,
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_account(&self) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Account,
inst_type: None,
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
pub async fn subscribe_positions(
&self,
inst_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Positions,
inst_type: Some(inst_type),
inst_family: None,
inst_id: None,
};
self.subscribe(vec![arg]).await
}
pub async fn unsubscribe_positions(
&self,
inst_type: OKXInstrumentType,
) -> Result<(), OKXWsError> {
let arg = OKXSubscriptionArg {
channel: OKXWsChannel::Positions,
inst_type: Some(inst_type),
inst_family: None,
inst_id: None,
};
self.unsubscribe(vec![arg]).await
}
async fn ws_batch_place_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest::<Value> {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::BatchOrders,
exp_time: None,
args,
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch orders: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_ORDER.to_vec()),
request_id: Some(request_id),
client_order_id: None,
op: Some(super::enums::OKXWsOperation::BatchOrders),
};
self.send_cmd(cmd).await
}
async fn ws_batch_cancel_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest::<Value> {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::BatchCancelOrders,
exp_time: None,
args,
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch cancel: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_CANCEL.to_vec()),
request_id: Some(request_id),
client_order_id: None,
op: Some(super::enums::OKXWsOperation::BatchCancelOrders),
};
self.send_cmd(cmd).await
}
async fn ws_batch_amend_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest::<Value> {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::BatchAmendOrders,
exp_time: None,
args,
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch amend: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_AMEND.to_vec()),
request_id: Some(request_id),
client_order_id: None,
op: Some(super::enums::OKXWsOperation::BatchAmendOrders),
};
self.send_cmd(cmd).await
}
#[expect(clippy::too_many_arguments)]
pub async fn submit_order(
&self,
trader_id: TraderId,
strategy_id: StrategyId,
instrument_id: InstrumentId,
td_mode: OKXTradeMode,
client_order_id: ClientOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
time_in_force: Option<TimeInForce>,
price: Option<Price>,
trigger_price: Option<Price>,
post_only: Option<bool>,
reduce_only: Option<bool>,
quote_quantity: Option<bool>,
position_side: Option<PositionSide>,
attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
px_usd: Option<String>,
px_vol: Option<String>,
speed_bump: Option<String>,
outcome: Option<String>,
slippage_pct: Option<String>,
rpi: Option<bool>,
rpi_taker_access: Option<bool>,
rpi_px_round: Option<bool>,
) -> Result<(), OKXWsError> {
let rpi = rpi.unwrap_or(false);
if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
return Err(OKXWsError::ClientError(format!(
"Unsupported order type: {order_type:?}",
)));
}
if let Some(tif) = time_in_force
&& !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
{
return Err(OKXWsError::ClientError(format!(
"Unsupported time in force: {tif:?}",
)));
}
let mut builder = WsPostOrderParamsBuilder::default();
let inst_id_code = self
.get_inst_id_code(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {instrument_id}, cannot submit order"
))
})?;
builder.inst_id_code(inst_id_code);
builder.td_mode(td_mode);
builder.cl_ord_id(client_order_id.as_str());
let (instrument_type, quote_currency) = {
let instruments = self.instruments_cache.load();
let symbol = instrument_id.symbol.inner();
let instrument = instruments.get(&symbol).ok_or_else(|| {
OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
})?;
let instrument_type = okx_instrument_type(instrument)
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
(instrument_type, instrument.quote_currency())
};
if instrument_type == OKXInstrumentType::Option
&& matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
{
return Err(OKXWsError::ClientError(
"Market orders are not supported for OKX options, use Limit orders instead"
.to_string(),
));
}
match instrument_type {
OKXInstrumentType::Spot => {
builder.ccy(quote_currency.to_string());
}
OKXInstrumentType::Margin => {
builder.ccy(quote_currency.to_string());
if let Some(ro) = reduce_only
&& ro
{
builder.reduce_only(ro);
}
}
OKXInstrumentType::Swap | OKXInstrumentType::Futures => {
builder.ccy(quote_currency.to_string());
if position_side.is_none() {
builder.pos_side(OKXPositionSide::Net);
}
}
OKXInstrumentType::Option => {
builder.ccy(quote_currency.to_string());
if position_side.is_none() {
builder.pos_side(OKXPositionSide::Net);
}
}
OKXInstrumentType::Events => {}
_ => {
builder.ccy(quote_currency.to_string());
if position_side.is_none() {
builder.pos_side(OKXPositionSide::Net);
}
if let Some(ro) = reduce_only
&& ro
{
builder.reduce_only(ro);
}
}
}
if let Some(attach_algo_ords) = attach_algo_ords {
builder.attach_algo_ords(attach_algo_ords);
}
if instrument_type == OKXInstrumentType::Spot
&& order_type == OrderType::Market
&& td_mode == OKXTradeMode::Cash
{
match quote_quantity {
Some(true) => {
builder.tgt_ccy(OKXTargetCurrency::QuoteCcy);
}
Some(false) if order_side == OrderSide::Buy => {
builder.tgt_ccy(OKXTargetCurrency::BaseCcy);
}
Some(false) | None => {}
}
}
builder.side(order_side.as_specified());
if let Some(pos_side) = position_side {
builder.pos_side(pos_side);
}
if rpi && order_type != OrderType::Limit {
return Err(OKXWsError::ClientError(
"OKX RPI orders require a limit order".to_string(),
));
}
let (okx_ord_type, price) = if rpi {
(OKXOrderType::Rpi, price)
} else if post_only.unwrap_or(false) {
(OKXOrderType::PostOnly, price)
} else if let Some(tif) = time_in_force {
match (order_type, tif) {
(OrderType::Market, TimeInForce::Fok) => {
return Err(OKXWsError::ClientError(
"Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string()
));
}
(OrderType::Market, TimeInForce::Ioc) => {
if matches!(
instrument_type,
OKXInstrumentType::Spot | OKXInstrumentType::Option
) {
(OKXOrderType::Market, price)
} else {
(OKXOrderType::OptimalLimitIoc, price)
}
}
(OrderType::Limit, TimeInForce::Fok) => {
if instrument_type == OKXInstrumentType::Option {
(OKXOrderType::OpFok, price)
} else {
(OKXOrderType::Fok, price)
}
}
(OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
_ => (OKXOrderType::from(order_type), price),
}
} else {
(OKXOrderType::from(order_type), price)
};
log::debug!(
"Order type mapping: order_type={order_type:?}, time_in_force={time_in_force:?}, post_only={post_only:?} -> okx_ord_type={okx_ord_type:?}"
);
let speed_bump = if instrument_type == OKXInstrumentType::Events {
if outcome.is_none() {
return Err(OKXWsError::ClientError(
"OKX event contract orders require `outcome`".to_string(),
));
}
if okx_ord_type == OKXOrderType::PostOnly {
speed_bump
} else {
Some(speed_bump.unwrap_or_else(|| "1".to_string()))
}
} else {
speed_bump
};
if let Some(speed_bump) = speed_bump {
builder.speed_bump(speed_bump);
}
if let Some(outcome) = outcome {
builder.outcome(outcome);
}
if let Some(slippage) = slippage_pct {
builder.slippage_pct(slippage);
}
if let Some(rpi_taker_access) = rpi_taker_access {
builder.rpi_taker_access(rpi_taker_access);
}
if let Some(rpi_px_round) = rpi_px_round {
builder.rpi_px_round(rpi_px_round);
}
builder.ord_type(okx_ord_type);
builder.sz(quantity.to_string());
if let Some(usd) = px_usd {
builder.px_usd(usd);
} else if let Some(vol) = px_vol {
builder.px_vol(vol);
} else if let Some(tp) = trigger_price {
builder.px(tp.to_string());
} else if let Some(p) = price {
builder.px(p.to_string());
}
builder.tag(OKX_NAUTILUS_BROKER_ID);
let params = builder
.build()
.map_err(|e| OKXWsError::ClientError(format!("Build order params error: {e}")))?;
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::Order,
exp_time: None,
args: vec![params],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize order: {e}")))?;
let cl_ord_key = client_order_id.to_string();
self.pending_orders.insert(
cl_ord_key.clone(),
PendingOrderInfo {
trader_id,
strategy_id,
instrument_id,
},
);
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ORDER.to_vec()),
request_id: Some(request_id),
client_order_id: Some(client_order_id),
op: Some(super::enums::OKXWsOperation::Order),
};
let result = self.send_cmd(cmd).await;
if result.is_err() {
self.pending_orders.remove(&cl_ord_key);
}
result
}
#[expect(clippy::too_many_arguments)]
pub async fn modify_order(
&self,
trader_id: TraderId,
strategy_id: StrategyId,
instrument_id: InstrumentId,
client_order_id: Option<ClientOrderId>,
price: Option<Price>,
quantity: Option<Quantity>,
venue_order_id: Option<VenueOrderId>,
new_px_usd: Option<String>,
new_px_vol: Option<String>,
speed_bump: Option<String>,
rpi_taker_access: Option<bool>,
rpi_px_round: Option<bool>,
) -> Result<(), OKXWsError> {
let mut builder = WsAmendOrderParamsBuilder::default();
let inst_id_code = self
.get_inst_id_code(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {instrument_id}, cannot amend order"
))
})?;
builder.inst_id_code(inst_id_code);
if let Some(venue_order_id) = venue_order_id {
builder.ord_id(venue_order_id.as_str());
}
let cl_ord_key = client_order_id.map(|id| id.to_string());
if let Some(client_order_id) = client_order_id {
builder.cl_ord_id(client_order_id.as_str());
self.pending_amends.insert(
client_order_id.to_string(),
PendingOrderInfo {
trader_id,
strategy_id,
instrument_id,
},
);
}
if let Some(usd) = new_px_usd {
builder.new_px_usd(usd);
} else if let Some(vol) = new_px_vol {
builder.new_px_vol(vol);
} else if let Some(price) = price {
builder.new_px(price.to_string());
}
if let Some(quantity) = quantity {
builder.new_sz(quantity.to_string());
}
if let Some(speed_bump) = speed_bump {
builder.speed_bump(speed_bump);
}
if let Some(rpi_taker_access) = rpi_taker_access {
builder.rpi_taker_access(rpi_taker_access);
}
if let Some(rpi_px_round) = rpi_px_round {
builder.rpi_px_round(rpi_px_round);
}
let params = builder
.build()
.map_err(|e| OKXWsError::ClientError(format!("Build amend params error: {e}")))?;
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::AmendOrder,
exp_time: None,
args: vec![params],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize amend: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_AMEND.to_vec()),
request_id: Some(request_id),
client_order_id,
op: Some(super::enums::OKXWsOperation::AmendOrder),
};
let result = self.send_cmd(cmd).await;
if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
self.pending_amends.remove(key);
}
result
}
pub async fn cancel_order(
&self,
trader_id: TraderId,
strategy_id: StrategyId,
instrument_id: InstrumentId,
client_order_id: Option<ClientOrderId>,
venue_order_id: Option<VenueOrderId>,
) -> Result<(), OKXWsError> {
let mut builder = WsCancelOrderParamsBuilder::default();
let inst_id_code = self
.get_inst_id_code(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {instrument_id}, cannot cancel order"
))
})?;
builder.inst_id_code(inst_id_code);
if let Some(venue_order_id) = venue_order_id {
builder.ord_id(venue_order_id.as_str());
}
let cl_ord_key = client_order_id.map(|id| id.to_string());
if let Some(client_order_id) = client_order_id {
builder.cl_ord_id(client_order_id.as_str());
self.pending_cancels.insert(
client_order_id.to_string(),
PendingOrderInfo {
trader_id,
strategy_id,
instrument_id,
},
);
}
let params = builder
.build()
.map_err(|e| OKXWsError::ClientError(format!("Build cancel params error: {e}")))?;
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::CancelOrder,
exp_time: None,
args: vec![params],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_CANCEL.to_vec()),
request_id: Some(request_id),
client_order_id,
op: Some(super::enums::OKXWsOperation::CancelOrder),
};
let result = self.send_cmd(cmd).await;
if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
self.pending_cancels.remove(key);
}
result
}
pub async fn mass_cancel_orders(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
let (inst_type, inst_family) = {
let instrument = self
.instruments_cache
.get_cloned(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
})?;
let inst_type = okx_instrument_type(&instrument)
.map_err(|e| OKXWsError::ClientError(e.to_string()))?;
let symbol = instrument.symbol().inner();
let inst_family = match &instrument {
InstrumentAny::CurrencyPair(_) => symbol.as_str().to_string(),
InstrumentAny::CryptoPerpetual(_) => symbol
.as_str()
.strip_suffix("-SWAP")
.unwrap_or(symbol.as_str())
.to_string(),
InstrumentAny::CryptoFuture(_) => {
let s = symbol.as_str();
if let Some(idx) = s.rfind('-') {
s[..idx].to_string()
} else {
s.to_string()
}
}
_ => {
return Err(OKXWsError::ClientError(
"Unsupported instrument type for mass cancel".to_string(),
));
}
};
(inst_type, inst_family)
};
let params = WsMassCancelParams {
inst_type,
inst_family: Ustr::from(&inst_family),
};
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::MassCancel,
exp_time: None,
args: vec![
serde_json::to_value(params).map_err(|e| OKXWsError::JsonError(e.to_string()))?,
],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize mass cancel: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_MASS_CANCEL.to_vec()),
request_id: Some(request_id),
client_order_id: None,
op: Some(super::enums::OKXWsOperation::MassCancel),
};
self.send_cmd(cmd).await
}
#[expect(clippy::type_complexity)]
pub async fn batch_submit_orders(
&self,
orders: Vec<(
OKXInstrumentType,
InstrumentId,
OKXTradeMode,
ClientOrderId,
OrderSide,
Option<PositionSide>,
OrderType,
Quantity,
Option<Price>,
Option<Price>,
Option<bool>,
Option<bool>,
Option<String>,
Option<String>,
Option<bool>,
Option<bool>,
Option<bool>,
)>,
) -> Result<(), OKXWsError> {
let args: Vec<Value> = {
let mut args = Vec::with_capacity(orders.len());
let inst_id_codes = self.inst_id_code_cache.load();
let instruments = self.instruments_cache.load();
for (
inst_type,
inst_id,
td_mode,
cl_ord_id,
ord_side,
pos_side,
ord_type,
qty,
pr,
tp,
post_only,
reduce_only,
speed_bump,
outcome,
rpi,
rpi_taker_access,
rpi_px_round,
) in orders
{
let rpi = rpi.unwrap_or(false);
let mut builder = WsPostOrderParamsBuilder::default();
let (inst_id_symbol, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
&inst_id_codes,
&inst_id,
"submit",
)?;
builder.inst_id_code(inst_id_code);
builder.td_mode(td_mode);
builder.cl_ord_id(cl_ord_id.as_str());
builder.side(ord_side.as_specified());
if inst_type != OKXInstrumentType::Events
&& let Some(instrument) = instruments.get(&inst_id_symbol)
{
builder.ccy(instrument.quote_currency().to_string());
}
if let Some(ps) = pos_side {
builder.pos_side(OKXPositionSide::from(ps));
} else if matches!(
inst_type,
OKXInstrumentType::Swap
| OKXInstrumentType::Futures
| OKXInstrumentType::Option
) {
builder.pos_side(OKXPositionSide::Net);
}
if rpi && ord_type != OrderType::Limit {
return Err(OKXWsError::ClientError(
"OKX RPI batch orders require limit orders".to_string(),
));
}
let okx_ord_type = if rpi {
OKXOrderType::Rpi
} else if post_only.unwrap_or(false) {
OKXOrderType::PostOnly
} else {
match ord_type {
OrderType::Market => OKXOrderType::Market,
OrderType::Limit => OKXOrderType::Limit,
OrderType::MarketToLimit => OKXOrderType::Ioc,
_ => {
return Err(OKXWsError::ClientError(format!(
"Unsupported order type for batch submit: {ord_type:?}"
)));
}
}
};
builder.ord_type(okx_ord_type);
builder.sz(qty.to_string());
if let Some(p) = pr {
builder.px(p.to_string());
} else if let Some(p) = tp {
builder.px(p.to_string());
}
if let Some(ro) = reduce_only {
builder.reduce_only(ro);
}
let speed_bump = if inst_type == OKXInstrumentType::Events {
if outcome.is_none() {
return Err(OKXWsError::ClientError(
"OKX event contract orders require `outcome`".to_string(),
));
}
if okx_ord_type == OKXOrderType::PostOnly {
speed_bump
} else {
Some(speed_bump.unwrap_or_else(|| "1".to_string()))
}
} else {
speed_bump
};
if let Some(speed_bump) = speed_bump {
builder.speed_bump(speed_bump);
}
if let Some(outcome) = outcome {
builder.outcome(outcome);
}
if let Some(rpi_taker_access) = rpi_taker_access {
builder.rpi_taker_access(rpi_taker_access);
}
if let Some(rpi_px_round) = rpi_px_round {
builder.rpi_px_round(rpi_px_round);
}
builder.tag(OKX_NAUTILUS_BROKER_ID);
let params = builder.build().map_err(|e| {
OKXWsError::ClientError(format!("Build order params error: {e}"))
})?;
let val = serde_json::to_value(params)
.map_err(|e| OKXWsError::JsonError(e.to_string()))?;
args.push(val);
}
args
};
self.ws_batch_place_orders(args).await
}
#[expect(clippy::type_complexity)]
pub async fn batch_modify_orders(
&self,
orders: Vec<(
OKXInstrumentType,
InstrumentId,
ClientOrderId,
Option<String>,
Option<Price>,
Option<Quantity>,
Option<String>,
Option<bool>,
Option<bool>,
)>,
) -> Result<(), OKXWsError> {
let args: Vec<Value> = {
let mut args = Vec::with_capacity(orders.len());
let inst_id_codes = self.inst_id_code_cache.load();
for (
_inst_type,
inst_id,
cl_ord_id,
request_id,
pr,
sz,
speed_bump,
rpi_taker_access,
rpi_px_round,
) in orders
{
let mut builder = WsAmendOrderParamsBuilder::default();
let (_, inst_id_code) =
Self::inst_id_symbol_and_code_from_snapshot(&inst_id_codes, &inst_id, "amend")?;
builder.inst_id_code(inst_id_code);
builder.cl_ord_id(cl_ord_id.as_str());
if let Some(request_id) = request_id {
builder.req_id(request_id);
}
if let Some(p) = pr {
builder.new_px(p.to_string());
}
if let Some(q) = sz {
builder.new_sz(q.to_string());
}
if let Some(speed_bump) = speed_bump {
builder.speed_bump(speed_bump);
}
if let Some(rpi_taker_access) = rpi_taker_access {
builder.rpi_taker_access(rpi_taker_access);
}
if let Some(rpi_px_round) = rpi_px_round {
builder.rpi_px_round(rpi_px_round);
}
let params = builder.build().map_err(|e| {
OKXWsError::ClientError(format!("Build amend batch params error: {e}"))
})?;
let val = serde_json::to_value(params)
.map_err(|e| OKXWsError::JsonError(e.to_string()))?;
args.push(val);
}
args
};
self.ws_batch_amend_orders(args).await
}
pub async fn batch_cancel_orders(
&self,
orders: Vec<(InstrumentId, Option<ClientOrderId>, Option<VenueOrderId>)>,
) -> Result<(), OKXWsError> {
let args: Vec<Value> = {
let mut args = Vec::with_capacity(orders.len());
let inst_id_codes = self.inst_id_code_cache.load();
for (inst_id, cl_ord_id, ord_id) in orders {
let mut builder = WsCancelOrderParamsBuilder::default();
let (_, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
&inst_id_codes,
&inst_id,
"cancel",
)?;
builder.inst_id_code(inst_id_code);
if let Some(c) = cl_ord_id {
builder.cl_ord_id(c.as_str());
}
if let Some(o) = ord_id {
builder.ord_id(o.as_str());
}
let params = builder.build().map_err(|e| {
OKXWsError::ClientError(format!("Build cancel batch params error: {e}"))
})?;
let val = serde_json::to_value(params)
.map_err(|e| OKXWsError::JsonError(e.to_string()))?;
args.push(val);
}
args
};
self.ws_batch_cancel_orders(args).await
}
#[expect(clippy::too_many_arguments)]
pub async fn submit_algo_order(
&self,
_trader_id: TraderId,
_strategy_id: StrategyId,
instrument_id: InstrumentId,
td_mode: OKXTradeMode,
client_order_id: ClientOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
trigger_price: Option<Price>,
trigger_type: Option<TriggerType>,
limit_price: Option<Price>,
reduce_only: Option<bool>,
callback_ratio: Option<String>,
callback_spread: Option<String>,
activation_price: Option<Price>,
) -> Result<(), OKXWsError> {
if !is_conditional_order(order_type) {
return Err(OKXWsError::ClientError(format!(
"Order type {order_type:?} is not a conditional order"
)));
}
let mut builder = WsPostAlgoOrderParamsBuilder::default();
if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
return Err(OKXWsError::ClientError(
"Invalid order side for OKX".to_string(),
));
}
let inst_id_code = self
.get_inst_id_code(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {instrument_id}, cannot submit algo order"
))
})?;
builder.inst_id_code(inst_id_code);
builder.td_mode(td_mode);
builder.cl_ord_id(client_order_id.as_str());
builder.side(order_side.as_specified());
builder.ord_type(
conditional_order_to_algo_type(order_type)
.map_err(|e| OKXWsError::ClientError(e.to_string()))?,
);
builder.sz(quantity.to_string());
if let Some(tp) = trigger_price {
builder.trigger_px(tp.to_string());
}
let okx_trigger_type = trigger_type.map_or(OKXTriggerType::Last, Into::into);
builder.trigger_px_type(okx_trigger_type);
if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
&& let Some(price) = limit_price
{
builder.order_px(price.to_string());
}
if let Some(reduce) = reduce_only {
builder.reduce_only(reduce);
}
if let Some(ratio) = callback_ratio {
builder.callback_ratio(ratio);
}
if let Some(spread) = callback_spread {
builder.callback_spread(spread);
}
if let Some(active) = activation_price {
builder.active_px(active.to_string());
}
builder.tag(OKX_NAUTILUS_BROKER_ID);
let params = builder
.build()
.map_err(|e| OKXWsError::ClientError(format!("Build algo order params error: {e}")))?;
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::OrderAlgo,
exp_time: None,
args: vec![params],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize algo order: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_ORDER.to_vec()),
request_id: Some(request_id),
client_order_id: Some(client_order_id),
op: Some(super::enums::OKXWsOperation::OrderAlgo),
};
self.send_cmd(cmd).await
}
pub async fn cancel_algo_order(
&self,
_trader_id: TraderId,
_strategy_id: StrategyId,
instrument_id: InstrumentId,
client_order_id: Option<ClientOrderId>,
algo_order_id: Option<String>,
) -> Result<(), OKXWsError> {
let mut builder = super::messages::WsCancelAlgoOrderParamsBuilder::default();
let inst_id_code = self
.get_inst_id_code(&instrument_id.symbol.inner())
.ok_or_else(|| {
OKXWsError::ClientError(format!(
"No instIdCode cached for {instrument_id}, cannot cancel algo order"
))
})?;
builder.inst_id_code(inst_id_code);
if let Some(algo_id) = algo_order_id {
builder.algo_id(algo_id);
}
if let Some(cl_ord_id) = client_order_id {
builder.algo_cl_ord_id(cl_ord_id.to_string());
}
let params = builder
.build()
.map_err(|e| OKXWsError::ClientError(format!("Build cancel algo params error: {e}")))?;
let request_id = self.generate_unique_request_id();
let request = OKXWsRequest {
id: Some(request_id.clone()),
op: super::enums::OKXWsOperation::CancelAlgos,
exp_time: None,
args: vec![params],
};
let payload = serde_json::to_string(&request)
.map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel algo: {e}")))?;
let cmd = HandlerCommand::Send {
payload,
rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_CANCEL.to_vec()),
request_id: Some(request_id),
client_order_id,
op: Some(super::enums::OKXWsOperation::CancelAlgos),
};
self.send_cmd(cmd).await
}
async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), OKXWsError> {
self.cmd_tx
.read()
.await
.send(cmd)
.map_err(|e| OKXWsError::ClientError(format!("Handler not available: {e}")))
}
}
fn ws_channel_for_book(channel: OKXBookChannel) -> OKXWsChannel {
match channel {
OKXBookChannel::Book => OKXWsChannel::Books,
OKXBookChannel::BookL2Tbt => OKXWsChannel::BooksTbt,
OKXBookChannel::Books50L2Tbt => OKXWsChannel::Books50Tbt,
OKXBookChannel::BooksRpi => OKXWsChannel::BooksRpi,
OKXBookChannel::SprdBooks5 => OKXWsChannel::SprdBooks5,
}
}
fn log_receiver_dropped(signal: &AtomicBool, item: &str) {
if signal.load(Ordering::Acquire) {
log::debug!("Receiver dropped after stop signal while forwarding {item}");
} else {
log::error!("Failed to send {item} through channel: receiver dropped");
}
}
#[cfg(test)]
mod tests {
use nautilus_core::time::get_atomic_clock_realtime;
use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
use nautilus_network::RECONNECTED;
use rstest::rstest;
use tokio_tungstenite::tungstenite::Message;
use super::*;
use crate::{
common::{
consts::OKX_POST_ONLY_CANCEL_SOURCE,
enums::{
OKXExecType, OKXOrderCategory, OKXOrderStatus, OKXPriceType, OKXQuickMarginType,
OKXSelfTradePreventionMode, OKXSide,
},
},
websocket::{
handler::is_post_only_auto_cancel,
messages::{OKXOrderMsg, OKXWebSocketError, OKXWsFrame},
},
};
#[rstest]
#[case(OKXBookChannel::Book, OKXWsChannel::Books)]
#[case(OKXBookChannel::BookL2Tbt, OKXWsChannel::BooksTbt)]
#[case(OKXBookChannel::Books50L2Tbt, OKXWsChannel::Books50Tbt)]
#[case(OKXBookChannel::BooksRpi, OKXWsChannel::BooksRpi)]
#[case(OKXBookChannel::SprdBooks5, OKXWsChannel::SprdBooks5)]
fn test_ws_channel_for_book(#[case] channel: OKXBookChannel, #[case] expected: OKXWsChannel) {
assert_eq!(ws_channel_for_book(channel), expected);
}
#[rstest]
fn test_timestamp_format_for_websocket_auth() {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("System time should be after UNIX epoch")
.as_secs()
.to_string();
timestamp.parse::<u64>().unwrap();
assert_eq!(timestamp.len(), 10);
assert!(timestamp.chars().all(|c| c.is_ascii_digit()));
}
#[rstest]
fn test_new_without_credentials() {
let client = OKXWebSocketClient::default();
assert!(client.credential.is_none());
assert_eq!(client.api_key(), None);
}
#[rstest]
fn test_instruments_cache_arc_observes_post_clone_writes() {
let client = OKXWebSocketClient::default();
let cache = client.instruments_cache_arc();
assert!(cache.load().is_empty());
let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
let symbol = instrument.symbol().inner();
client.cache_instruments(std::slice::from_ref(&instrument));
let loaded = cache.load();
assert_eq!(loaded.len(), 1);
let stored = loaded.get(&symbol).expect("instrument not refreshed");
assert_eq!(stored.id(), instrument.id());
}
#[rstest]
fn test_add_option_greeks_sub_defaults_to_both_conventions() {
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
client.add_option_greeks_sub(instrument_id);
let subs = client.option_greeks_subs().load();
let stored = subs.get(&instrument_id).expect("instrument not registered");
assert_eq!(stored.len(), 2);
assert!(stored.contains(&OKXGreeksType::Bs));
assert!(stored.contains(&OKXGreeksType::Pa));
}
#[rstest]
#[case::bs_only(vec![OKXGreeksType::Bs])]
#[case::pa_only(vec![OKXGreeksType::Pa])]
#[case::both(vec![OKXGreeksType::Bs, OKXGreeksType::Pa])]
fn test_add_option_greeks_sub_with_conventions_stores_requested_set(
#[case] conventions: Vec<OKXGreeksType>,
) {
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
let set: AHashSet<OKXGreeksType> = conventions.iter().copied().collect();
client.add_option_greeks_sub_with_conventions(instrument_id, set.clone());
let subs = client.option_greeks_subs().load();
let stored = subs.get(&instrument_id).expect("instrument not registered");
assert_eq!(stored, &set);
}
#[rstest]
fn test_add_option_greeks_sub_with_empty_conventions_falls_back_to_both() {
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
client.add_option_greeks_sub_with_conventions(instrument_id, AHashSet::new());
let subs = client.option_greeks_subs().load();
let stored = subs.get(&instrument_id).expect("instrument not registered");
assert_eq!(stored.len(), 2);
}
#[rstest]
fn test_remove_option_greeks_sub_clears_entry() {
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
client.add_option_greeks_sub(instrument_id);
client.remove_option_greeks_sub(&instrument_id);
let subs = client.option_greeks_subs().load();
assert!(!subs.contains_key(&instrument_id));
}
#[rstest]
fn test_new_with_credentials() {
let client = OKXWebSocketClient::new(
None,
Some("test_key".to_string()),
Some("test_secret".to_string()),
Some("test_passphrase".to_string()),
None,
None,
None,
TransportBackend::default(),
None,
)
.unwrap();
assert!(client.credential.is_some());
assert_eq!(client.api_key(), Some("test_key"));
}
#[rstest]
fn test_new_partial_credentials_fails() {
let result = OKXWebSocketClient::new(
None,
Some("test_key".to_string()),
None,
Some("test_passphrase".to_string()),
None,
None,
None,
TransportBackend::default(),
None,
);
result.unwrap_err();
}
#[rstest]
fn test_request_id_generation() {
let client = OKXWebSocketClient::default();
let initial_counter = client.request_id_counter.load(Ordering::SeqCst);
let id1 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
let id2 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
assert_eq!(id1, initial_counter);
assert_eq!(id2, initial_counter + 1);
assert_eq!(
client.request_id_counter.load(Ordering::SeqCst),
initial_counter + 2
);
}
#[rstest]
fn test_client_state_management() {
let client = OKXWebSocketClient::default();
assert!(client.is_closed());
assert!(!client.is_active());
let client_with_heartbeat = OKXWebSocketClient::new(
None,
None,
None,
None,
None,
Some(30),
None,
TransportBackend::default(),
None,
)
.unwrap();
assert!(client_with_heartbeat.heartbeat.is_some());
assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
}
#[rstest]
fn test_websocket_error_handling() {
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns().as_u64();
let error = OKXWebSocketError {
code: "60012".to_string(),
message: "Invalid request".to_string(),
conn_id: None,
timestamp: ts,
};
assert_eq!(error.code, "60012");
assert_eq!(error.message, "Invalid request");
assert_eq!(error.timestamp, ts);
let nautilus_msg = OKXWsMessage::Error(error);
match nautilus_msg {
OKXWsMessage::Error(e) => {
assert_eq!(e.code, "60012");
assert_eq!(e.message, "Invalid request");
}
_ => panic!("Expected Error variant"),
}
}
#[rstest]
fn test_request_id_generation_sequence() {
let client = OKXWebSocketClient::default();
let initial_counter = client
.request_id_counter
.load(std::sync::atomic::Ordering::SeqCst);
let mut ids = Vec::new();
for _ in 0..10 {
let id = client
.request_id_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
ids.push(id);
}
for (i, &id) in ids.iter().enumerate() {
assert_eq!(id, initial_counter + i as u64);
}
assert_eq!(
client
.request_id_counter
.load(std::sync::atomic::Ordering::SeqCst),
initial_counter + 10
);
}
#[rstest]
fn test_client_state_transitions() {
let client = OKXWebSocketClient::default();
assert!(client.is_closed());
assert!(!client.is_active());
let client_with_heartbeat = OKXWebSocketClient::new(
None,
None,
None,
None,
None,
Some(30), None,
TransportBackend::default(),
None,
)
.unwrap();
assert!(client_with_heartbeat.heartbeat.is_some());
assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
let account_id = AccountId::from("test-account-123");
let client_with_account = OKXWebSocketClient::new(
None,
None,
None,
None,
Some(account_id),
None,
None,
TransportBackend::default(),
None,
)
.unwrap();
assert_eq!(client_with_account.account_id, account_id);
}
#[rstest]
fn test_websocket_error_scenarios() {
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns().as_u64();
let error_scenarios = vec![
("60012", "Invalid request", None),
("60009", "Invalid API key", Some("conn-123".to_string())),
("60014", "Too many requests", None),
("50001", "Order not found", None),
];
for (code, message, conn_id) in error_scenarios {
let error = OKXWebSocketError {
code: code.to_string(),
message: message.to_string(),
conn_id: conn_id.clone(),
timestamp: ts,
};
assert_eq!(error.code, code);
assert_eq!(error.message, message);
assert_eq!(error.conn_id, conn_id);
assert_eq!(error.timestamp, ts);
let nautilus_msg = OKXWsMessage::Error(error);
match nautilus_msg {
OKXWsMessage::Error(e) => {
assert_eq!(e.code, code);
assert_eq!(e.message, message);
assert_eq!(e.conn_id, conn_id);
}
_ => panic!("Expected Error variant"),
}
}
}
#[rstest]
fn test_feed_handler_reconnection_detection() {
let msg = Message::Text(RECONNECTED.to_string().into());
let result = OKXWsFeedHandler::parse_raw_message(msg);
assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
}
#[rstest]
fn test_feed_handler_normal_message_processing() {
let ping_msg = Message::Text(TEXT_PING.to_string().into());
let result = OKXWsFeedHandler::parse_raw_message(ping_msg);
assert!(matches!(result, Some(OKXWsFrame::Ping)));
let sub_msg = r#"{
"event": "subscribe",
"arg": {
"channel": "tickers",
"instType": "SPOT"
},
"connId": "a4d3ae55"
}"#;
let sub_result =
OKXWsFeedHandler::parse_raw_message(Message::Text(sub_msg.to_string().into()));
assert!(matches!(sub_result, Some(OKXWsFrame::Subscription { .. })));
}
#[rstest]
fn test_feed_handler_close_message() {
let result = OKXWsFeedHandler::parse_raw_message(Message::Close(None));
assert!(result.is_none());
}
#[rstest]
fn test_reconnection_message_constant() {
assert_eq!(RECONNECTED, "__RECONNECTED__");
}
#[rstest]
fn test_multiple_reconnection_signals() {
for _ in 0..3 {
let msg = Message::Text(RECONNECTED.to_string().into());
let result = OKXWsFeedHandler::parse_raw_message(msg);
assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
}
}
#[tokio::test]
async fn test_wait_until_active_timeout() {
let client = OKXWebSocketClient::new(
None,
Some("test_key".to_string()),
Some("test_secret".to_string()),
Some("test_passphrase".to_string()),
Some(AccountId::from("test-account")),
None,
None,
TransportBackend::default(),
None,
)
.unwrap();
let result = client.wait_until_active(0.1).await;
assert!(result.is_err());
assert!(!client.is_active());
}
fn sample_canceled_order_msg() -> OKXOrderMsg {
OKXOrderMsg {
acc_fill_sz: Some("0".to_string()),
avg_px: "0".to_string(),
c_time: 0,
cancel_source: None,
cancel_source_reason: None,
category: OKXOrderCategory::Normal,
ccy: Ustr::from("USDT"),
cl_ord_id: "order-1".to_string(),
algo_cl_ord_id: None,
attach_algo_cl_ord_id: None,
attach_algo_ords: Vec::new(),
outcome: None,
fee: None,
fee_ccy: Ustr::from("USDT"),
fill_px: "0".to_string(),
fill_sz: "0".to_string(),
fill_time: 0,
inst_id: Ustr::from("ETH-USDT-SWAP"),
inst_type: OKXInstrumentType::Swap,
lever: "1".to_string(),
ord_id: Ustr::from("123456"),
ord_type: OKXOrderType::Limit,
pnl: "0".to_string(),
pos_side: OKXPositionSide::Net,
px: "0".to_string(),
reduce_only: "false".to_string(),
side: OKXSide::Buy,
state: OKXOrderStatus::Canceled,
exec_type: OKXExecType::None,
sz: "1".to_string(),
td_mode: OKXTradeMode::Cross,
tgt_ccy: None,
trade_id: String::new(),
algo_id: None,
fill_fee: None,
fill_fee_ccy: None,
fill_mark_px: None,
fill_mark_vol: None,
fill_px_vol: None,
fill_px_usd: None,
fill_fwd_px: None,
fill_notional_usd: None,
fill_pnl: None,
is_tp_limit: None,
linked_algo_ord: None,
notional_usd: None,
px_type: OKXPriceType::None,
px_usd: None,
px_vol: None,
quick_mgn_type: OKXQuickMarginType::None,
rebate: None,
rebate_ccy: None,
sl_ord_px: None,
sl_trigger_px: None,
sl_trigger_px_type: None,
source: None,
stp_id: None,
stp_mode: OKXSelfTradePreventionMode::None,
tag: None,
tp_ord_px: None,
tp_trigger_px: None,
tp_trigger_px_type: None,
amend_result: None,
req_id: None,
code: None,
msg: None,
u_time: 0,
}
}
#[rstest]
fn test_is_post_only_auto_cancel_detects_cancel_source() {
let mut msg = sample_canceled_order_msg();
msg.cancel_source = Some(OKX_POST_ONLY_CANCEL_SOURCE.to_string());
assert!(is_post_only_auto_cancel(&msg));
}
#[rstest]
fn test_is_post_only_auto_cancel_detects_reason() {
let mut msg = sample_canceled_order_msg();
msg.cancel_source_reason = Some("POST_ONLY would take liquidity".to_string());
assert!(is_post_only_auto_cancel(&msg));
}
#[rstest]
fn test_is_post_only_auto_cancel_false_without_markers() {
let msg = sample_canceled_order_msg();
assert!(!is_post_only_auto_cancel(&msg));
}
#[rstest]
fn test_is_post_only_auto_cancel_false_for_order_type_only() {
let mut msg = sample_canceled_order_msg();
msg.ord_type = OKXOrderType::PostOnly;
assert!(!is_post_only_auto_cancel(&msg));
}
#[tokio::test]
async fn test_batch_cancel_orders_with_multiple_orders() {
use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let client_order_id1 = ClientOrderId::new("order1");
let client_order_id2 = ClientOrderId::new("order2");
let venue_order_id1 = VenueOrderId::new("venue1");
let venue_order_id2 = VenueOrderId::new("venue2");
let orders = vec![
(instrument_id, Some(client_order_id1), Some(venue_order_id1)),
(instrument_id, Some(client_order_id2), Some(venue_order_id2)),
];
let result = client.batch_cancel_orders(orders).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_batch_cancel_orders_with_only_client_order_id() {
use nautilus_model::identifiers::{ClientOrderId, InstrumentId};
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let client_order_id = ClientOrderId::new("order1");
let orders = vec![(instrument_id, Some(client_order_id), None)];
let result = client.batch_cancel_orders(orders).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_batch_cancel_orders_with_only_venue_order_id() {
use nautilus_model::identifiers::{InstrumentId, VenueOrderId};
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let instrument_id = InstrumentId::from("BTC-USDT.OKX");
let venue_order_id = VenueOrderId::new("venue1");
let orders = vec![(instrument_id, None, Some(venue_order_id))];
let result = client.batch_cancel_orders(orders).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_batch_cancel_orders_with_both_ids() {
use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let client_order_id = ClientOrderId::new("order1");
let venue_order_id = VenueOrderId::new("venue1");
let orders = vec![(instrument_id, Some(client_order_id), Some(venue_order_id))];
let result = client.batch_cancel_orders(orders).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_cancel_order_fails_without_inst_id_code() {
use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
let result = client
.cancel_order(
TraderId::from("TESTER-001"),
StrategyId::from("S-001"),
instrument_id,
Some(ClientOrderId::new("O-001")),
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("No instIdCode cached for BTC-USDT-SWAP.OKX"),
"Expected instIdCode error, found: {err}"
);
}
#[tokio::test]
async fn test_submit_order_fails_without_inst_id_code() {
use nautilus_model::{
enums::{OrderSide, OrderType},
identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
types::Quantity,
};
use crate::common::enums::OKXTradeMode;
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
let result = client
.submit_order(
TraderId::from("TESTER-001"),
StrategyId::from("S-001"),
instrument_id,
OKXTradeMode::Cross,
ClientOrderId::new("O-001"),
OrderSide::Buy,
OrderType::Limit,
Quantity::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("No instIdCode cached for ETH-USDT-SWAP.OKX"),
"Expected instIdCode error, found: {err}"
);
}
#[tokio::test]
async fn test_cancel_order_passes_inst_id_code_lookup_when_cached() {
use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
use ustr::Ustr;
let client = OKXWebSocketClient::default();
let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
client.cache_inst_id_code(Ustr::from("BTC-USDT-SWAP"), 10459);
let result = client
.cancel_order(
TraderId::from("TESTER-001"),
StrategyId::from("S-001"),
instrument_id,
Some(ClientOrderId::new("O-001")),
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
!err.contains("No instIdCode cached"),
"Should pass instIdCode lookup, found: {err}"
);
}
#[rstest]
fn test_race_unsubscribe_failure_recovery() {
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let topic = "trades:BTC-USDT-SWAP";
client.subscriptions_state.mark_subscribe(topic);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
client.subscriptions_state.mark_unsubscribe(topic);
assert_eq!(client.subscriptions_state.len(), 0);
assert_eq!(
client.subscriptions_state.pending_unsubscribe_topics(),
vec![topic]
);
client.subscriptions_state.confirm_unsubscribe(topic); client.subscriptions_state.mark_subscribe(topic); client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
assert!(
client
.subscriptions_state
.pending_unsubscribe_topics()
.is_empty()
);
assert!(
client
.subscriptions_state
.pending_subscribe_topics()
.is_empty()
);
let all = client.subscriptions_state.all_topics();
assert_eq!(all.len(), 1);
assert!(all.contains(&topic.to_string()));
}
#[rstest]
fn test_race_resubscribe_before_unsubscribe_ack() {
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let topic = "books:BTC-USDT";
client.subscriptions_state.mark_subscribe(topic);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
client.subscriptions_state.mark_unsubscribe(topic);
assert_eq!(client.subscriptions_state.len(), 0);
assert_eq!(
client.subscriptions_state.pending_unsubscribe_topics(),
vec![topic]
);
client.subscriptions_state.mark_subscribe(topic);
assert_eq!(
client.subscriptions_state.pending_subscribe_topics(),
vec![topic]
);
client.subscriptions_state.confirm_unsubscribe(topic);
assert!(
client
.subscriptions_state
.pending_unsubscribe_topics()
.is_empty()
);
assert_eq!(
client.subscriptions_state.pending_subscribe_topics(),
vec![topic]
);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
assert!(
client
.subscriptions_state
.pending_subscribe_topics()
.is_empty()
);
let all = client.subscriptions_state.all_topics();
assert_eq!(all.len(), 1);
assert!(all.contains(&topic.to_string()));
}
#[rstest]
fn test_race_late_subscribe_confirmation_after_unsubscribe() {
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let topic = "tickers:ETH-USDT";
client.subscriptions_state.mark_subscribe(topic);
assert_eq!(
client.subscriptions_state.pending_subscribe_topics(),
vec![topic]
);
client.subscriptions_state.mark_unsubscribe(topic);
assert!(
client
.subscriptions_state
.pending_subscribe_topics()
.is_empty()
); assert_eq!(
client.subscriptions_state.pending_unsubscribe_topics(),
vec![topic]
);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 0); assert_eq!(
client.subscriptions_state.pending_unsubscribe_topics(),
vec![topic]
);
client.subscriptions_state.confirm_unsubscribe(topic);
assert!(client.subscriptions_state.is_empty());
assert!(client.subscriptions_state.all_topics().is_empty());
}
#[rstest]
fn test_race_reconnection_with_pending_states() {
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
Some("test_key".to_string()),
Some("test_secret".to_string()),
Some("test_passphrase".to_string()),
Some(AccountId::new("OKX-TEST")),
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let trade_btc = "trades:BTC-USDT-SWAP";
client.subscriptions_state.mark_subscribe(trade_btc);
client.subscriptions_state.confirm_subscribe(trade_btc);
let trade_eth = "trades:ETH-USDT-SWAP";
client.subscriptions_state.mark_subscribe(trade_eth);
let book_btc = "books:BTC-USDT";
client.subscriptions_state.mark_subscribe(book_btc);
client.subscriptions_state.confirm_subscribe(book_btc);
client.subscriptions_state.mark_unsubscribe(book_btc);
let topics_to_restore = client.subscriptions_state.all_topics();
assert_eq!(topics_to_restore.len(), 2);
assert!(topics_to_restore.contains(&trade_btc.to_string()));
assert!(topics_to_restore.contains(&trade_eth.to_string()));
assert!(!topics_to_restore.contains(&book_btc.to_string())); }
#[rstest]
fn test_race_duplicate_subscribe_messages_idempotent() {
let client = OKXWebSocketClient::new(
Some("wss://test.okx.com".to_string()),
None,
None,
None,
None,
None,
None,
TransportBackend::default(),
None,
)
.expect("Failed to create client");
let topic = "trades:BTC-USDT-SWAP";
client.subscriptions_state.mark_subscribe(topic);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
client.subscriptions_state.mark_subscribe(topic);
assert!(
client
.subscriptions_state
.pending_subscribe_topics()
.is_empty()
); assert_eq!(client.subscriptions_state.len(), 1);
client.subscriptions_state.confirm_subscribe(topic);
assert_eq!(client.subscriptions_state.len(), 1);
let all = client.subscriptions_state.all_topics();
assert_eq!(all.len(), 1);
assert_eq!(all[0], topic);
}
}