1use std::{
26 fmt::Debug,
27 num::NonZeroU32,
28 sync::{
29 Arc, LazyLock,
30 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
31 },
32 time::Duration,
33};
34
35use ahash::{AHashMap, AHashSet};
36use arc_swap::ArcSwap;
37use dashmap::DashMap;
38use futures_util::Stream;
39use nautilus_common::live::dst::time;
40use nautilus_core::{
41 AtomicMap, AtomicTime, UnixNanos,
42 env::{get_env_var, get_or_env_var},
43 string::secret::{REDACTED, SecretString},
44 time::get_atomic_clock_realtime,
45};
46use nautilus_live::{
47 SocketControl,
48 task::{TaskGroup, TaskShutdownError},
49};
50use nautilus_model::{
51 data::BarType,
52 enums::{OrderSide, OrderType, PositionSide, TimeInForce, TriggerType},
53 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
54 instruments::{Instrument, InstrumentAny},
55 types::{Price, Quantity},
56};
57use nautilus_network::{
58 http::create_standard_nautilus_headers,
59 mode::ConnectionMode,
60 ratelimiter::quota::Quota,
61 websocket::{
62 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, SubscriptionState, TEXT_PING, TransportBackend,
63 WebSocketClient, WebSocketConfig, channel_message_handler,
64 },
65};
66use parking_lot::Mutex;
67use serde_json::Value;
68use tokio_tungstenite::tungstenite::Error;
69use tokio_util::sync::CancellationToken;
70use ustr::Ustr;
71
72use super::{
73 enums::OKXWsChannel,
74 error::OKXWsError,
75 handler::{HandlerCommand, OKXWsFeedHandler, SnapshotGate},
76 messages::{
77 OKXAuthentication, OKXAuthenticationArg, OKXSubscriptionArg, OKXWsMessage, OKXWsRequest,
78 WsAmendOrderParamsBuilder, WsAttachAlgoOrdParams, WsCancelOrderParamsBuilder,
79 WsMassCancelParams, WsPostAlgoOrderParamsBuilder, WsPostOrderParamsBuilder,
80 },
81 subscription::topic_from_subscription_arg,
82};
83use crate::common::{
84 consts::{
85 OKX_NAUTILUS_BROKER_ID, OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
86 OKX_WS_PUBLIC_URL, OKX_WS_TOPIC_DELIMITER, okx_reduce_only_wire_value, select_book_channel,
87 spot_trade_quote_ccy_wire_value,
88 },
89 credential::Credential,
90 enums::{
91 OKXBookChannel, OKXGreeksType, OKXInstrumentType, OKXOrderType, OKXPositionSide,
92 OKXTargetCurrency, OKXTradeMode, OKXTriggerType, OKXVipLevel,
93 conditional_order_to_algo_type, is_conditional_order,
94 },
95 parse::{
96 bar_spec_as_okx_channel, okx_instrument_type, okx_instrument_type_from_symbol,
97 parse_base_quote_from_symbol,
98 },
99};
100
101pub static OKX_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
105 Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant")
106});
107
108pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
113 LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).expect("non-zero")));
114
115pub static OKX_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
117 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
118});
119
120pub static OKX_WS_BATCH_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
122 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant")
123});
124
125pub static OKX_WS_MASS_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
127 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
128});
129
130pub static OKX_WS_ALGO_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
132 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
133});
134
135pub static OKX_WS_ALGO_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
137 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
138});
139
140const OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE: usize = 256;
142
143const RECONNECT_AUTH_RETRY_INITIAL: Duration = Duration::from_secs(1);
144const RECONNECT_AUTH_RETRY_MAX: Duration = Duration::from_secs(30);
145
146pub static OKX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
151 LazyLock::new(|| [Ustr::from("subscription")]);
152
153pub static OKX_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("order")]);
157
158pub static OKX_RATE_LIMIT_KEY_BATCH_ORDER: LazyLock<[Ustr; 1]> =
162 LazyLock::new(|| [Ustr::from("batch-order")]);
163
164pub static OKX_RATE_LIMIT_KEY_CANCEL: LazyLock<[Ustr; 1]> =
168 LazyLock::new(|| [Ustr::from("cancel")]);
169
170pub static OKX_RATE_LIMIT_KEY_BATCH_CANCEL: LazyLock<[Ustr; 1]> =
174 LazyLock::new(|| [Ustr::from("batch-cancel")]);
175
176pub static OKX_RATE_LIMIT_KEY_MASS_CANCEL: LazyLock<[Ustr; 1]> =
180 LazyLock::new(|| [Ustr::from("mass-cancel")]);
181
182pub static OKX_RATE_LIMIT_KEY_AMEND: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("amend")]);
186
187pub static OKX_RATE_LIMIT_KEY_BATCH_AMEND: LazyLock<[Ustr; 1]> =
191 LazyLock::new(|| [Ustr::from("batch-amend")]);
192
193pub static OKX_RATE_LIMIT_KEY_ALGO_ORDER: LazyLock<[Ustr; 1]> =
197 LazyLock::new(|| [Ustr::from("algo-order")]);
198
199pub static OKX_RATE_LIMIT_KEY_ALGO_CANCEL: LazyLock<[Ustr; 1]> =
203 LazyLock::new(|| [Ustr::from("algo-cancel")]);
204
205#[derive(Debug, Clone)]
209#[allow(dead_code)]
210#[allow(
211 clippy::struct_field_names,
212 reason = "fields follow the codebase-wide trader_id/strategy_id/instrument_id naming family"
213)]
214pub(crate) struct PendingOrderInfo {
215 pub trader_id: TraderId,
216 pub strategy_id: StrategyId,
217 pub instrument_id: InstrumentId,
218}
219
220#[derive(Clone)]
222pub struct OKXWebSocketClient {
223 clock: &'static AtomicTime,
224 url: String,
225 vip_level: Arc<AtomicU8>,
226 credential: Option<Credential>,
227 heartbeat: Option<u64>,
228 auth_timeout_secs: u64,
229 auth_tracker: AuthTracker,
230 signal: Arc<AtomicBool>,
231 connection_mode: Arc<ArcSwap<AtomicU8>>,
232 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
233 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<OKXWsMessage>>>,
234 handler_tasks: Arc<TaskGroup>,
235 connect_lock: Arc<tokio::sync::Mutex<()>>,
236 handler_abort: Arc<Mutex<CancellationToken>>,
237 subscriptions_inst_type: Arc<DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>>,
238 subscriptions_inst_family: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
239 subscriptions_inst_id: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
240 subscriptions_bare: Arc<DashMap<OKXWsChannel, bool>>,
241 subscriptions_state: SubscriptionState,
242 request_id_counter: Arc<AtomicU64>,
243 instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
244 inst_id_code_cache: Arc<AtomicMap<Ustr, u64>>,
245 trade_quote_ccy_lists: Arc<AtomicMap<Ustr, Vec<Ustr>>>,
246 spot_trade_quote_ccy: Arc<Mutex<Option<Ustr>>>,
247 pub(crate) pending_orders: Arc<DashMap<String, PendingOrderInfo>>,
248 pub(crate) pending_cancels: Arc<DashMap<String, PendingOrderInfo>>,
249 pub(crate) pending_amends: Arc<DashMap<String, PendingOrderInfo>>,
250 option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
251 index_pair_subscribers: Arc<DashMap<Ustr, usize>>,
258 index_pair_transition: Arc<tokio::sync::Mutex<()>>,
263 transport_backend: TransportBackend,
265 proxy_url: Option<SecretString>,
267 cancellation_token: CancellationToken,
268 socket_control: Option<Arc<SocketControl>>,
269}
270
271struct ConnectRollback {
272 handler_tasks: Arc<TaskGroup>,
273 signal: Arc<AtomicBool>,
274 handler_abort: CancellationToken,
275 socket_control: Option<Arc<SocketControl>>,
276 armed: bool,
277}
278
279impl ConnectRollback {
280 fn disarm(&mut self) {
281 self.armed = false;
282 }
283}
284
285impl Drop for ConnectRollback {
286 fn drop(&mut self) {
287 if !self.armed {
288 return;
289 }
290
291 self.handler_tasks.begin_shutdown();
292 self.signal.store(true, Ordering::Release);
293 self.handler_abort.cancel();
294
295 if let Some(control) = &self.socket_control {
296 control.deregister();
297 }
298 }
299}
300
301impl Default for OKXWebSocketClient {
302 fn default() -> Self {
303 Self::new(
304 None,
305 None,
306 None,
307 None,
308 None,
309 None,
310 None,
311 TransportBackend::default(),
312 None,
313 )
314 .unwrap()
315 }
316}
317
318impl Debug for OKXWebSocketClient {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 f.debug_struct(stringify!(OKXWebSocketClient))
321 .field("url", &self.url)
322 .field("credential", &self.credential.as_ref().map(|_| REDACTED))
323 .field("heartbeat", &self.heartbeat)
324 .finish_non_exhaustive()
325 }
326}
327
328impl OKXWebSocketClient {
329 #[allow(clippy::too_many_arguments)]
335 pub fn new(
336 url: Option<String>,
337 api_key: Option<String>,
338 api_secret: Option<String>,
339 api_passphrase: Option<String>,
340 _account_id: Option<AccountId>,
341 heartbeat: Option<u64>,
342 auth_timeout_secs: Option<u64>,
343 transport_backend: TransportBackend,
344 proxy_url: Option<String>,
345 ) -> anyhow::Result<Self> {
346 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
347 let credential = match (api_key, api_secret, api_passphrase) {
348 (Some(key), Some(secret), Some(passphrase)) => {
349 Some(Credential::new(key, secret, passphrase))
350 }
351 (None, None, None) => None,
352 _ => anyhow::bail!(
353 "`api_key`, `api_secret`, `api_passphrase` credentials must be provided together"
354 ),
355 };
356
357 let signal = Arc::new(AtomicBool::new(false));
358 let subscriptions_inst_type = Arc::new(DashMap::new());
359 let subscriptions_inst_family = Arc::new(DashMap::new());
360 let subscriptions_inst_id = Arc::new(DashMap::new());
361 let subscriptions_bare = Arc::new(DashMap::new());
362 let subscriptions_state = SubscriptionState::new(OKX_WS_TOPIC_DELIMITER);
363
364 Ok(Self {
365 clock: get_atomic_clock_realtime(),
366 url,
367 vip_level: Arc::new(AtomicU8::new(0)),
368 credential,
369 heartbeat,
370 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
371 auth_tracker: AuthTracker::new(),
372 signal,
373 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
374 ConnectionMode::Closed.as_u8(),
375 ))),
376 cmd_tx: {
377 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
379 Arc::new(tokio::sync::RwLock::new(tx))
380 },
381 out_rx: None,
382 handler_tasks: Arc::new(TaskGroup::new()),
383 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
384 handler_abort: Arc::new(Mutex::new(CancellationToken::new())),
385 subscriptions_inst_type,
386 subscriptions_inst_family,
387 subscriptions_inst_id,
388 subscriptions_bare,
389 subscriptions_state,
390 request_id_counter: Arc::new(AtomicU64::new(1)),
391 instruments_cache: Arc::new(AtomicMap::new()),
392 inst_id_code_cache: Arc::new(AtomicMap::new()),
393 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
394 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
395 pending_orders: Arc::new(DashMap::new()),
396 pending_cancels: Arc::new(DashMap::new()),
397 pending_amends: Arc::new(DashMap::new()),
398 option_greeks_subs: Arc::new(AtomicMap::new()),
399 index_pair_subscribers: Arc::new(DashMap::new()),
400 index_pair_transition: Arc::new(tokio::sync::Mutex::new(())),
401 transport_backend,
402 proxy_url: proxy_url.map(SecretString::from),
403 cancellation_token: CancellationToken::new(),
404 socket_control: None,
405 })
406 }
407
408 #[must_use]
410 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
411 self.socket_control = Some(Arc::new(control));
412 self
413 }
414
415 #[allow(clippy::too_many_arguments)]
422 pub fn with_credentials(
423 url: Option<String>,
424 api_key: Option<String>,
425 api_secret: Option<String>,
426 api_passphrase: Option<String>,
427 account_id: Option<AccountId>,
428 heartbeat: Option<u64>,
429 auth_timeout_secs: Option<u64>,
430 transport_backend: TransportBackend,
431 proxy_url: Option<String>,
432 ) -> anyhow::Result<Self> {
433 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
434 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
435 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
436 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
437
438 Self::new(
439 Some(url),
440 Some(api_key),
441 Some(api_secret),
442 Some(api_passphrase),
443 account_id,
444 heartbeat,
445 auth_timeout_secs,
446 transport_backend,
447 proxy_url,
448 )
449 }
450
451 pub fn from_env() -> anyhow::Result<Self> {
458 let url = get_env_var("OKX_WS_URL")?;
459 let api_key = get_env_var("OKX_API_KEY")?;
460 let api_secret = get_env_var("OKX_API_SECRET")?;
461 let api_passphrase = get_env_var("OKX_API_PASSPHRASE")?;
462
463 Self::new(
464 Some(url),
465 Some(api_key),
466 Some(api_secret),
467 Some(api_passphrase),
468 None,
469 None,
470 None,
471 TransportBackend::default(),
472 None,
473 )
474 }
475
476 pub fn cancel_all_requests(&self) {
478 self.cancellation_token.cancel();
479 }
480
481 pub fn cancellation_token(&self) -> &CancellationToken {
483 &self.cancellation_token
484 }
485
486 pub fn url(&self) -> &str {
488 self.url.as_str()
489 }
490
491 pub fn api_key(&self) -> Option<&str> {
493 self.credential.as_ref().map(Credential::api_key)
494 }
495
496 #[must_use]
498 pub fn api_key_masked(&self) -> Option<String> {
499 self.credential.as_ref().map(Credential::api_key_masked)
500 }
501
502 pub fn is_active(&self) -> bool {
504 let connection_mode_arc = self.connection_mode.load();
505 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
506 && !self.signal.load(Ordering::Acquire)
507 }
508
509 pub fn is_closed(&self) -> bool {
511 let connection_mode_arc = self.connection_mode.load();
512 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
513 || self.signal.load(Ordering::Acquire)
514 }
515
516 pub(crate) fn has_task(&self) -> bool {
518 !self.handler_tasks.is_empty()
519 }
520
521 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
525 self.instruments_cache.rcu(|m| {
526 for inst in instruments {
527 m.insert(inst.symbol().inner(), inst.clone());
528 }
529 });
530 }
531
532 pub fn cache_instrument(&self, instrument: InstrumentAny) {
536 self.instruments_cache
537 .insert(instrument.symbol().inner(), instrument);
538 }
539
540 pub fn instruments_snapshot(&self) -> AHashMap<Ustr, InstrumentAny> {
542 (**self.instruments_cache.load()).clone()
543 }
544
545 pub fn instruments_cache_arc(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
547 Arc::clone(&self.instruments_cache)
548 }
549
550 pub fn cache_inst_id_code(&self, inst_id: Ustr, inst_id_code: u64) {
554 self.inst_id_code_cache.insert(inst_id, inst_id_code);
555 }
556
557 pub fn cache_inst_id_codes(&self, mappings: impl IntoIterator<Item = (Ustr, u64)>) {
561 let entries: Vec<_> = mappings.into_iter().collect();
562 self.inst_id_code_cache.rcu(|m| {
563 for (inst_id, inst_id_code) in &entries {
564 m.insert(*inst_id, *inst_id_code);
565 }
566 });
567 }
568
569 #[must_use]
573 pub fn get_inst_id_code(&self, inst_id: &Ustr) -> Option<u64> {
574 self.inst_id_code_cache.load().get(inst_id).copied()
575 }
576
577 pub fn set_spot_trade_quote_ccy(&self, ccy: Option<String>) {
579 *self.spot_trade_quote_ccy.lock() = ccy.map(|value| Ustr::from(value.as_str()));
580 }
581
582 pub fn cache_trade_quote_ccy_lists(
584 &self,
585 mappings: impl IntoIterator<Item = (Ustr, Vec<Ustr>)>,
586 ) {
587 let entries: Vec<_> = mappings.into_iter().collect();
588 self.trade_quote_ccy_lists.rcu(|m| {
589 for (inst_id, list) in &entries {
590 m.insert(*inst_id, list.clone());
591 }
592 });
593 }
594
595 fn resolve_spot_trade_quote_ccy(
596 &self,
597 instrument_type: OKXInstrumentType,
598 inst_id: Ustr,
599 ) -> Result<Option<Ustr>, OKXWsError> {
600 let configured = *self.spot_trade_quote_ccy.lock();
601 let available = self
602 .trade_quote_ccy_lists
603 .load()
604 .get(&inst_id)
605 .cloned()
606 .unwrap_or_default();
607 spot_trade_quote_ccy_wire_value(
608 instrument_type,
609 configured.as_ref().map(Ustr::as_str),
610 &available,
611 )
612 .map_err(OKXWsError::ClientError)
613 }
614
615 fn inst_id_symbol_and_code_from_snapshot(
616 inst_id_codes: &AHashMap<Ustr, u64>,
617 inst_id: &InstrumentId,
618 action: &str,
619 ) -> Result<(Ustr, u64), OKXWsError> {
620 let inst_id_symbol = inst_id.symbol.inner();
621 let inst_id_code = inst_id_codes.get(&inst_id_symbol).copied().ok_or_else(|| {
622 OKXWsError::ClientError(format!(
623 "No instIdCode cached for {inst_id}, cannot {action} order"
624 ))
625 })?;
626 Ok((inst_id_symbol, inst_id_code))
627 }
628
629 pub fn set_vip_level(&self, vip_level: OKXVipLevel) {
633 self.vip_level.store(vip_level as u8, Ordering::Relaxed);
634 }
635
636 pub fn vip_level(&self) -> OKXVipLevel {
638 let level = self.vip_level.load(Ordering::Relaxed);
639 OKXVipLevel::from(level)
640 }
641
642 pub async fn connect(&mut self) -> anyhow::Result<()> {
648 let connect_lock = Arc::clone(&self.connect_lock);
649 let _connect_guard = connect_lock.lock().await;
650
651 if !self.handler_tasks.is_empty() && !self.handler_tasks.all_finished() {
652 anyhow::bail!("Cannot connect while previous WebSocket handler task is still running");
653 }
654
655 if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
656 self.handler_tasks.begin_shutdown();
657 self.handler_tasks
658 .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
659 .await
660 .map_err(|e| anyhow::anyhow!("Previous WebSocket handler failed: {e}"))?;
661 self.handler_tasks.start_generation().map_err(|e| {
662 anyhow::anyhow!("Failed to start WebSocket handler task generation: {e}")
663 })?;
664 }
665 let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
666 anyhow::anyhow!("Failed to acquire WebSocket handler task spawner: {e}")
667 })?;
668 let handler_abort = CancellationToken::new();
669 *self.handler_abort.lock() = handler_abort.clone();
670 let mut rollback = ConnectRollback {
671 handler_tasks: Arc::clone(&self.handler_tasks),
672 signal: Arc::clone(&self.signal),
673 handler_abort: handler_abort.clone(),
674 socket_control: self.socket_control.clone(),
675 armed: true,
676 };
677
678 self.signal.store(false, Ordering::Release);
680
681 let (message_handler, raw_rx) = channel_message_handler();
682
683 let headers = create_standard_nautilus_headers();
689
690 let config = WebSocketConfig {
691 url: self.url.clone(),
692 headers,
693 heartbeat_interval_secs: self.heartbeat,
694 heartbeat_payload: Some(TEXT_PING.to_string()),
695 connect_timeout_ms: None,
696 reconnect_delay_initial_ms: None,
697 reconnect_delay_max_ms: None,
698 reconnect_backoff_factor: None,
699 reconnect_jitter_ms: None,
700 reconnect_max_attempts: None,
701 heartbeat_timeout_secs: None,
702 idle_timeout_ms: None,
703 backend: self.transport_backend,
704 proxy_url: self
705 .proxy_url
706 .as_ref()
707 .map(|value| value.expose_secret().to_owned()),
708 };
709
710 let keyed_quotas = vec![
711 (
712 OKX_RATE_LIMIT_KEY_SUBSCRIPTION[0].to_string(),
713 *OKX_WS_SUBSCRIPTION_QUOTA,
714 ),
715 (OKX_RATE_LIMIT_KEY_ORDER[0].to_string(), *OKX_WS_ORDER_QUOTA),
716 (
717 OKX_RATE_LIMIT_KEY_BATCH_ORDER[0].to_string(),
718 *OKX_WS_BATCH_ORDER_QUOTA,
719 ),
720 (
721 OKX_RATE_LIMIT_KEY_CANCEL[0].to_string(),
722 *OKX_WS_ORDER_QUOTA,
723 ),
724 (
725 OKX_RATE_LIMIT_KEY_BATCH_CANCEL[0].to_string(),
726 *OKX_WS_BATCH_ORDER_QUOTA,
727 ),
728 (
729 OKX_RATE_LIMIT_KEY_MASS_CANCEL[0].to_string(),
730 *OKX_WS_MASS_CANCEL_QUOTA,
731 ),
732 (OKX_RATE_LIMIT_KEY_AMEND[0].to_string(), *OKX_WS_ORDER_QUOTA),
733 (
734 OKX_RATE_LIMIT_KEY_BATCH_AMEND[0].to_string(),
735 *OKX_WS_BATCH_ORDER_QUOTA,
736 ),
737 (
738 OKX_RATE_LIMIT_KEY_ALGO_ORDER[0].to_string(),
739 *OKX_WS_ALGO_ORDER_QUOTA,
740 ),
741 (
742 OKX_RATE_LIMIT_KEY_ALGO_CANCEL[0].to_string(),
743 *OKX_WS_ALGO_CANCEL_QUOTA,
744 ),
745 ];
746
747 let client = WebSocketClient::builder()
748 .config(config)
749 .message_handler(message_handler)
750 .keyed_quotas(keyed_quotas)
751 .default_quota(*OKX_WS_CONNECTION_QUOTA)
752 .maybe_state_sink(self.socket_control.as_ref().map(|control| control.sink()))
753 .connect()
754 .await?;
755
756 self.connection_mode.store(client.connection_mode_atomic());
758 let reconnect_handle = client.reconnect_handle();
759
760 let (msg_tx, rx) = tokio::sync::mpsc::unbounded_channel::<OKXWsMessage>();
761
762 self.out_rx = Some(Arc::new(rx));
763
764 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
765 *self.cmd_tx.write().await = cmd_tx.clone();
766
767 let signal = self.signal.clone();
768 let auth_tracker = self.auth_tracker.clone();
769 let subscriptions_state = self.subscriptions_state.clone();
770 let clock = self.clock;
771
772 let handler_task = {
773 let auth_tracker = auth_tracker.clone();
774 let signal = signal.clone();
775 let credential = self.credential.clone();
776 let cmd_tx_for_reconnect = cmd_tx.clone();
777 let subscriptions_bare = self.subscriptions_bare.clone();
778 let subscriptions_inst_type = self.subscriptions_inst_type.clone();
779 let subscriptions_inst_family = self.subscriptions_inst_family.clone();
780 let subscriptions_inst_id = self.subscriptions_inst_id.clone();
781 let cmd_tx_for_auth = Arc::clone(&self.cmd_tx);
782 let auth_tracker_for_auth = self.auth_tracker.clone();
783 let clock_for_auth = self.clock;
784 let auth_timeout_secs = self.auth_timeout_secs;
785 let reconnect_auth_generation = Arc::new(AtomicU64::new(0));
786 let handler_spawner_for_auth = handler_spawner.clone();
787 let mut has_reconnected = false;
788
789 async move {
790 let mut handler = OKXWsFeedHandler::new(
791 signal.clone(),
792 cmd_rx,
793 raw_rx,
794 msg_tx,
795 auth_tracker.clone(),
796 subscriptions_state.clone(),
797 clock,
798 );
799
800 let resubscribe_all = || {
801 let args = subscription_args(
802 &subscriptions_inst_type,
803 &subscriptions_inst_family,
804 &subscriptions_inst_id,
805 &subscriptions_bare,
806 );
807
808 for chunk in subscription_arg_batches(&args) {
809 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
810 args: chunk.to_vec(),
811 }) {
812 log::error!("Failed to send resubscribe command: error={e}");
813 }
814 }
815 };
816
817 loop {
818 let message = tokio::select! {
819 biased;
820 () = handler_abort.cancelled() => {
821 log::debug!("Handler task aborted");
822 break;
823 }
824 message = handler.next() => message,
825 };
826
827 match message {
828 Some(OKXWsMessage::Reconnected) => {
829 if signal.load(Ordering::Acquire) {
830 continue;
831 }
832
833 has_reconnected = true;
834
835 subscriptions_state.reset_after_reconnect();
836
837 if let Some(credential) = credential.clone() {
838 log::debug!("Re-authenticating after reconnection");
839 let generation =
840 reconnect_auth_generation.fetch_add(1, Ordering::AcqRel) + 1;
841 let signal = signal.clone();
842 let abort = handler_abort.clone();
843 let generation_flag = Arc::clone(&reconnect_auth_generation);
844 let cmd_tx = Arc::clone(&cmd_tx_for_auth);
845 let auth_tracker = auth_tracker_for_auth.clone();
846
847 if let Err(e) = handler_spawner_for_auth.spawn(async move {
848 retry_reconnect_authentication(
849 credential,
850 auth_tracker,
851 cmd_tx,
852 clock_for_auth,
853 auth_timeout_secs,
854 signal,
855 abort,
856 generation_flag,
857 generation,
858 )
859 .await;
860 }) {
861 log::error!(
862 "Failed to spawn re-authentication retry task: error={e}"
863 );
864 }
865 }
866
867 if credential.is_none() {
870 log::debug!(
871 "No authentication required, resubscribing immediately"
872 );
873 resubscribe_all();
874 }
875
876 if handler.send(OKXWsMessage::Reconnected).is_err() {
878 log_receiver_dropped(&signal, "Reconnected");
879 break;
880 }
881 }
882 Some(OKXWsMessage::Authenticated) => {
883 if has_reconnected {
884 resubscribe_all();
885 }
886 }
887 Some(msg) => {
888 if handler.send(msg).is_err() {
889 log_receiver_dropped(&signal, "message");
890 break;
891 }
892 }
893 None => {
894 if handler.is_stopped() {
895 log::debug!("Stop signal received, ending message processing");
896 break;
897 }
898 log::debug!("WebSocket stream closed");
899 break;
900 }
901 }
902 }
903
904 log::debug!("Handler task exiting");
905 }
906 };
907
908 if let Err(e) = handler_spawner.spawn(handler_task) {
909 self.out_rx = None;
910 anyhow::bail!("Failed to register WebSocket handler task: {e}");
911 }
912
913 let set_client_result = {
914 let cmd_tx = self.cmd_tx.read().await;
915 cmd_tx.send(HandlerCommand::SetClient(client))
916 };
917
918 if let Err(e) = set_client_result {
919 self.handler_tasks.begin_shutdown();
920 self.signal.store(true, Ordering::Release);
921 let handler_abort = self.handler_abort.lock().clone();
922 handler_abort.cancel();
923 let shutdown_result = self.close_stream_task(Duration::from_secs(2)).await;
924 self.out_rx = None;
925 anyhow::bail!(match shutdown_result {
926 Ok(()) => format!("Failed to send WebSocket client to handler: {e}"),
927 Err(shutdown_error) => format!(
928 "Failed to send WebSocket client to handler: {e}; handler shutdown failed: \
929 {shutdown_error}"
930 ),
931 });
932 }
933
934 if let Some(control) = &self.socket_control {
935 control.register(move || reconnect_handle.request_reconnect());
936 }
937 log::debug!("Sent WebSocket client to handler");
938
939 if self.credential.is_some()
940 && let Err(e) = self.authenticate().await
941 {
942 self.handler_tasks.begin_shutdown();
943 self.request_close().await;
944 let shutdown_result = self.close_stream_task(Duration::from_secs(2)).await;
945
946 if let Some(control) = &self.socket_control {
947 control.deregister();
948 }
949 self.out_rx = None;
950
951 match shutdown_result {
952 Ok(()) => anyhow::bail!("Authentication failed: {e}"),
953 Err(shutdown_error) => anyhow::bail!(
954 "Authentication failed: {e}; handler shutdown failed: {shutdown_error}"
955 ),
956 }
957 }
958
959 rollback.disarm();
960 Ok(())
961 }
962
963 async fn authenticate(&self) -> Result<(), Error> {
965 let credential = self.credential.as_ref().ok_or_else(|| {
966 Error::Io(std::io::Error::other(
967 "API credentials not available to authenticate",
968 ))
969 })?;
970
971 match authenticate_session(
972 credential,
973 &self.auth_tracker,
974 &self.cmd_tx,
975 self.clock,
976 self.auth_timeout_secs,
977 )
978 .await
979 {
980 Ok(()) => Ok(()),
981 Err(e) if auth_attempt_superseded(&e) => {
982 if self
985 .auth_tracker
986 .wait_for_authenticated(Duration::from_secs(self.auth_timeout_secs))
987 .await
988 {
989 Ok(())
990 } else {
991 Err(e)
992 }
993 }
994 Err(e) => Err(e),
995 }
996 }
997
998 pub fn stream(&mut self) -> impl Stream<Item = OKXWsMessage> + 'static {
1006 let rx = self
1007 .out_rx
1008 .take()
1009 .expect("Data stream receiver already taken or not connected");
1010 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
1011 async_stream::stream! {
1012 while let Some(data) = rx.recv().await {
1013 yield data;
1014 }
1015 }
1016 }
1017
1018 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), OKXWsError> {
1024 let timeout = time::Duration::from_secs_f64(timeout_secs);
1025
1026 time::timeout(timeout, async {
1027 while !self.is_active() {
1028 time::sleep(time::Duration::from_millis(10)).await;
1029 }
1030 })
1031 .await
1032 .map_err(|_| {
1033 OKXWsError::ClientError(format!(
1034 "WebSocket connection timeout after {timeout_secs} seconds"
1035 ))
1036 })?;
1037
1038 Ok(())
1039 }
1040
1041 pub(crate) fn begin_shutdown(&self) {
1042 self.handler_tasks.begin_shutdown();
1043 self.signal.store(true, Ordering::Release);
1044
1045 let handler_abort = self.handler_abort.lock().clone();
1046 handler_abort.cancel();
1047 }
1048
1049 pub(crate) async fn request_close(&self) {
1051 self.signal.store(true, Ordering::Release);
1052
1053 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
1054 log::debug!("Handler channel closed before disconnect command was sent: {e}");
1055 } else {
1056 log::debug!("Sent disconnect command to handler");
1057 }
1058 }
1059
1060 pub async fn close(&mut self) -> Result<(), Error> {
1067 let connect_lock = Arc::clone(&self.connect_lock);
1068 let _connect_guard = connect_lock.lock().await;
1069
1070 self.close_locked().await
1071 }
1072
1073 async fn close_locked(&self) -> Result<(), Error> {
1074 log::debug!("Starting close process");
1075
1076 self.handler_tasks.begin_shutdown();
1077 self.request_close().await;
1078
1079 let task_result = self.close_stream_task(Duration::from_secs(2)).await;
1080
1081 self.index_pair_subscribers.clear();
1085
1086 if let Some(control) = &self.socket_control {
1087 control.deregister();
1088 }
1089
1090 log::debug!("Close process completed");
1091
1092 task_result
1093 }
1094
1095 async fn close_stream_task(&self, timeout: Duration) -> Result<(), Error> {
1096 match self.handler_tasks.finish_shutdown(timeout, timeout).await {
1097 Ok(()) => Ok(()),
1098 Err(error @ TaskShutdownError::Timeout { .. }) => Err(Error::Io(std::io::Error::new(
1099 std::io::ErrorKind::TimedOut,
1100 format!("Timed out joining WebSocket handler task after abort: {error}"),
1101 ))),
1102 Err(e) => Err(Error::Io(std::io::Error::other(format!(
1103 "WebSocket handler shutdown failed: {e}"
1104 )))),
1105 }
1106 }
1107
1108 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<OKXWsChannel> {
1110 let symbol = instrument_id.symbol.inner();
1111 let mut channels = Vec::new();
1112
1113 for entry in self.subscriptions_inst_id.iter() {
1114 let (channel, instruments) = entry.pair();
1115 if instruments.contains(&symbol) {
1116 channels.push(channel.clone());
1117 }
1118 }
1119
1120 channels
1121 }
1122
1123 fn generate_unique_request_id(&self) -> String {
1124 self.request_id_counter
1125 .fetch_add(1, Ordering::SeqCst)
1126 .to_string()
1127 }
1128
1129 async fn subscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1130 self.cmd_tx
1132 .read()
1133 .await
1134 .send(HandlerCommand::Subscribe { args: args.clone() })
1135 .map_err(|e| {
1136 OKXWsError::ClientError(format!("Failed to send subscribe command: {e}"))
1137 })?;
1138
1139 self.record_subscriptions(&args);
1140 Ok(())
1141 }
1142
1143 fn record_subscriptions(&self, args: &[OKXSubscriptionArg]) {
1144 for arg in args {
1145 let topic = topic_from_subscription_arg(arg);
1146 self.subscriptions_state.mark_subscribe(&topic);
1147
1148 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1150 self.subscriptions_bare.insert(arg.channel.clone(), true);
1151 } else {
1152 if let Some(inst_type) = &arg.inst_type {
1153 self.subscriptions_inst_type
1154 .entry(arg.channel.clone())
1155 .or_default()
1156 .insert(*inst_type);
1157 }
1158
1159 if let Some(inst_family) = &arg.inst_family {
1160 self.subscriptions_inst_family
1161 .entry(arg.channel.clone())
1162 .or_default()
1163 .insert(*inst_family);
1164 }
1165
1166 if let Some(inst_id) = &arg.inst_id {
1167 self.subscriptions_inst_id
1168 .entry(arg.channel.clone())
1169 .or_default()
1170 .insert(*inst_id);
1171 }
1172 }
1173 }
1174 }
1175
1176 #[expect(clippy::collapsible_if)]
1177 async fn unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1178 self.cmd_tx
1180 .read()
1181 .await
1182 .send(HandlerCommand::Unsubscribe { args: args.clone() })
1183 .map_err(|e| {
1184 OKXWsError::ClientError(format!("Failed to send unsubscribe command: {e}"))
1185 })?;
1186
1187 for arg in &args {
1188 let topic = topic_from_subscription_arg(arg);
1189 self.subscriptions_state.mark_unsubscribe(&topic);
1190
1191 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1192 self.subscriptions_bare.remove(&arg.channel);
1193 } else {
1194 if let Some(inst_type) = &arg.inst_type {
1195 if let Some(mut entry) = self.subscriptions_inst_type.get_mut(&arg.channel) {
1196 entry.remove(inst_type);
1197 if entry.is_empty() {
1198 drop(entry);
1199 self.subscriptions_inst_type.remove(&arg.channel);
1200 }
1201 }
1202 }
1203
1204 if let Some(inst_family) = &arg.inst_family {
1205 if let Some(mut entry) = self.subscriptions_inst_family.get_mut(&arg.channel) {
1206 entry.remove(inst_family);
1207 if entry.is_empty() {
1208 drop(entry);
1209 self.subscriptions_inst_family.remove(&arg.channel);
1210 }
1211 }
1212 }
1213
1214 if let Some(inst_id) = &arg.inst_id {
1215 if let Some(mut entry) = self.subscriptions_inst_id.get_mut(&arg.channel) {
1216 entry.remove(inst_id);
1217 if entry.is_empty() {
1218 drop(entry);
1219 self.subscriptions_inst_id.remove(&arg.channel);
1220 }
1221 }
1222 }
1223 }
1224 }
1225
1226 Ok(())
1227 }
1228
1229 async fn subscribe_inst_id(
1230 &self,
1231 channel: OKXWsChannel,
1232 inst_id: Ustr,
1233 ) -> Result<(), OKXWsError> {
1234 self.subscribe(vec![OKXSubscriptionArg {
1235 channel,
1236 inst_type: None,
1237 inst_family: None,
1238 inst_id: Some(inst_id),
1239 }])
1240 .await
1241 }
1242
1243 async fn unsubscribe_inst_id(
1244 &self,
1245 channel: OKXWsChannel,
1246 inst_id: Ustr,
1247 ) -> Result<(), OKXWsError> {
1248 self.unsubscribe(vec![OKXSubscriptionArg {
1249 channel,
1250 inst_type: None,
1251 inst_family: None,
1252 inst_id: Some(inst_id),
1253 }])
1254 .await
1255 }
1256
1257 pub async fn unsubscribe_all(&self) -> Result<(), OKXWsError> {
1266 let all_args = subscription_args(
1267 &self.subscriptions_inst_type,
1268 &self.subscriptions_inst_family,
1269 &self.subscriptions_inst_id,
1270 &self.subscriptions_bare,
1271 );
1272
1273 if all_args.is_empty() {
1274 log::debug!("No active subscriptions to unsubscribe from");
1275 return Ok(());
1276 }
1277
1278 log::debug!("Batched unsubscribe from {} channels", all_args.len());
1279
1280 for chunk in subscription_arg_batches(&all_args) {
1281 self.unsubscribe(chunk.to_vec()).await?;
1282 }
1283
1284 self.index_pair_subscribers.clear();
1288
1289 Ok(())
1290 }
1291
1292 pub async fn subscribe_instruments(
1304 &self,
1305 instrument_type: OKXInstrumentType,
1306 ) -> Result<(), OKXWsError> {
1307 let arg = OKXSubscriptionArg {
1308 channel: OKXWsChannel::Instruments,
1309 inst_type: Some(instrument_type),
1310 inst_family: None,
1311 inst_id: None,
1312 };
1313 self.subscribe(vec![arg]).await
1314 }
1315
1316 pub async fn subscribe_instrument(
1330 &self,
1331 instrument_id: InstrumentId,
1332 ) -> Result<(), OKXWsError> {
1333 let inst_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
1334 log::debug!("Subscribing to instrument type {inst_type:?} for {instrument_id}");
1335 self.subscribe_instruments(inst_type).await
1336 }
1337
1338 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1347 self.subscribe_book_with_depth(instrument_id, 0).await
1348 }
1349
1350 pub async fn subscribe_book_rpi(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1356 self.subscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1357 .await
1358 }
1359
1360 pub(crate) async fn subscribe_book_channel(
1361 &self,
1362 instrument_id: InstrumentId,
1363 channel: OKXBookChannel,
1364 cancel: CancellationToken,
1365 gate: SnapshotGate,
1366 ) -> Result<(), OKXWsError> {
1367 let (completion, receiver) = tokio::sync::oneshot::channel();
1368 let sender = self.cmd_tx.read().await;
1369
1370 if cancel.is_cancelled() {
1371 return Ok(());
1372 }
1373
1374 let subscription = OKXSubscriptionArg {
1375 channel: ws_channel_for_book(channel),
1376 inst_type: None,
1377 inst_family: None,
1378 inst_id: Some(instrument_id.symbol.inner()),
1379 };
1380
1381 self.record_subscriptions(std::slice::from_ref(&subscription));
1382 sender
1383 .send(HandlerCommand::SubscribeBook {
1384 subscription,
1385 cancel: cancel.clone(),
1386 gate,
1387 completion,
1388 })
1389 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?;
1390
1391 drop(sender);
1392 tokio::select! {
1393 biased;
1394 () = cancel.cancelled() => Ok(()),
1395 result = receiver => result.map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?,
1396 }
1397 }
1398
1399 pub(crate) async fn resubscribe_book_channel(
1401 &self,
1402 instrument_id: InstrumentId,
1403 channel: OKXBookChannel,
1404 cancel: CancellationToken,
1405 gate: SnapshotGate,
1406 ) -> Result<(), OKXWsError> {
1407 let (completion, receiver) = tokio::sync::oneshot::channel();
1408 let sender = self.cmd_tx.read().await;
1409
1410 if cancel.is_cancelled() {
1411 return Err(OKXWsError::ClientError("Book recovery canceled".into()));
1412 }
1413
1414 sender
1415 .send(HandlerCommand::Resubscribe {
1416 subscription: OKXSubscriptionArg {
1417 channel: ws_channel_for_book(channel),
1418 inst_type: None,
1419 inst_family: None,
1420 inst_id: Some(instrument_id.symbol.inner()),
1421 },
1422 cancel,
1423 gate,
1424 completion,
1425 })
1426 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?;
1427
1428 drop(sender);
1429 receiver
1430 .await
1431 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?
1432 }
1433
1434 pub async fn subscribe_book_depth5(
1446 &self,
1447 instrument_id: InstrumentId,
1448 ) -> Result<(), OKXWsError> {
1449 self.subscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1450 .await
1451 }
1452
1453 pub async fn subscribe_book50_l2_tbt(
1465 &self,
1466 instrument_id: InstrumentId,
1467 ) -> Result<(), OKXWsError> {
1468 self.subscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1469 .await
1470 }
1471
1472 pub async fn subscribe_book_l2_tbt(
1484 &self,
1485 instrument_id: InstrumentId,
1486 ) -> Result<(), OKXWsError> {
1487 self.subscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1488 .await
1489 }
1490
1491 pub async fn subscribe_book_with_depth(
1505 &self,
1506 instrument_id: InstrumentId,
1507 depth: u16,
1508 ) -> anyhow::Result<()> {
1509 let vip = self.vip_level();
1510
1511 if !matches!(depth, 0 | 50 | 400) {
1512 anyhow::bail!("Invalid depth {depth}, must be 0, 50, or 400");
1513 }
1514
1515 if depth == 50 && vip < OKXVipLevel::Vip4 {
1516 anyhow::bail!("VIP level {vip} insufficient for 50 depth subscription (requires VIP4)");
1517 }
1518
1519 let channel = select_book_channel(depth as usize, vip);
1520 self.subscribe_inst_id(ws_channel_for_book(channel), instrument_id.symbol.inner())
1521 .await?;
1522 Ok(())
1523 }
1524
1525 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1538 self.subscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1539 .await
1540 }
1541
1542 pub async fn subscribe_trades(
1556 &self,
1557 instrument_id: InstrumentId,
1558 aggregated: bool,
1559 ) -> Result<(), OKXWsError> {
1560 let channel = if aggregated {
1561 OKXWsChannel::TradesAll
1562 } else {
1563 OKXWsChannel::Trades
1564 };
1565 self.subscribe_inst_id(channel, instrument_id.symbol.inner())
1566 .await
1567 }
1568
1569 pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1581 self.subscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1582 .await
1583 }
1584
1585 pub async fn subscribe_mark_prices(
1597 &self,
1598 instrument_id: InstrumentId,
1599 ) -> Result<(), OKXWsError> {
1600 self.subscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1601 .await
1602 }
1603
1604 pub async fn subscribe_index_prices(
1616 &self,
1617 instrument_id: InstrumentId,
1618 ) -> Result<(), OKXWsError> {
1619 let symbol = instrument_id.symbol.inner();
1621 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1622 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1623 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1624
1625 let _guard = self.index_pair_transition.lock().await;
1631
1632 let is_first = {
1637 let mut count = self.index_pair_subscribers.entry(base_pair).or_insert(0);
1638 *count += 1;
1639 *count == 1
1640 };
1641
1642 if !is_first {
1643 return Ok(());
1644 }
1645
1646 let arg = OKXSubscriptionArg {
1647 channel: OKXWsChannel::IndexTickers,
1648 inst_type: None,
1649 inst_family: None,
1650 inst_id: Some(base_pair),
1651 };
1652
1653 match self.subscribe(vec![arg]).await {
1654 Ok(()) => Ok(()),
1655 Err(e) => {
1656 self.index_pair_subscribers.remove(&base_pair);
1665 Err(e)
1666 }
1667 }
1668 }
1669
1670 pub async fn subscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1683 let arg = OKXSubscriptionArg {
1684 channel: OKXWsChannel::OptionSummary,
1685 inst_type: None,
1686 inst_family: Some(inst_family),
1687 inst_id: None,
1688 };
1689 self.subscribe(vec![arg]).await
1690 }
1691
1692 pub async fn subscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1702 let arg = OKXSubscriptionArg {
1703 channel: OKXWsChannel::EventContractMarkets,
1704 inst_type: Some(OKXInstrumentType::Events),
1705 inst_family: None,
1706 inst_id: None,
1707 };
1708 self.subscribe(vec![arg]).await
1709 }
1710
1711 pub fn option_greeks_subs(&self) -> &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>> {
1715 &self.option_greeks_subs
1716 }
1717
1718 pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1721 let both: AHashSet<OKXGreeksType> =
1722 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
1723 self.option_greeks_subs.insert(instrument_id, both);
1724 }
1725
1726 pub fn add_option_greeks_sub_with_conventions(
1729 &self,
1730 instrument_id: InstrumentId,
1731 conventions: AHashSet<OKXGreeksType>,
1732 ) {
1733 let set = if conventions.is_empty() {
1734 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
1735 } else {
1736 conventions
1737 };
1738 self.option_greeks_subs.insert(instrument_id, set);
1739 }
1740
1741 pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1743 self.option_greeks_subs.remove(instrument_id);
1744 }
1745
1746 pub async fn subscribe_funding_rates(
1758 &self,
1759 instrument_id: InstrumentId,
1760 ) -> Result<(), OKXWsError> {
1761 self.subscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1762 .await
1763 }
1764
1765 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
1777 let channel = bar_spec_as_okx_channel(bar_type.spec())
1779 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1780 self.subscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
1781 .await
1782 }
1783
1784 pub async fn unsubscribe_instruments(
1790 &self,
1791 instrument_type: OKXInstrumentType,
1792 ) -> Result<(), OKXWsError> {
1793 let arg = OKXSubscriptionArg {
1794 channel: OKXWsChannel::Instruments,
1795 inst_type: Some(instrument_type),
1796 inst_family: None,
1797 inst_id: None,
1798 };
1799 self.unsubscribe(vec![arg]).await
1800 }
1801
1802 #[allow(
1812 clippy::unused_async,
1813 clippy::unused_async_trait_impl,
1814 reason = "async signature is kept for parity with the other unsubscribe methods and callers"
1815 )]
1816 pub async fn unsubscribe_instrument(
1817 &self,
1818 instrument_id: InstrumentId,
1819 ) -> Result<(), OKXWsError> {
1820 log::debug!("Instrument unsubscribe is a no-op (shared per-type channel): {instrument_id}");
1821 Ok(())
1822 }
1823
1824 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1830 self.unsubscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1831 .await
1832 }
1833
1834 pub async fn unsubscribe_book_rpi(
1840 &self,
1841 instrument_id: InstrumentId,
1842 ) -> Result<(), OKXWsError> {
1843 self.unsubscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1844 .await
1845 }
1846
1847 pub async fn unsubscribe_book_depth5(
1853 &self,
1854 instrument_id: InstrumentId,
1855 ) -> Result<(), OKXWsError> {
1856 self.unsubscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1857 .await
1858 }
1859
1860 pub async fn unsubscribe_book50_l2_tbt(
1866 &self,
1867 instrument_id: InstrumentId,
1868 ) -> Result<(), OKXWsError> {
1869 self.unsubscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1870 .await
1871 }
1872
1873 pub async fn unsubscribe_book_l2_tbt(
1879 &self,
1880 instrument_id: InstrumentId,
1881 ) -> Result<(), OKXWsError> {
1882 self.unsubscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1883 .await
1884 }
1885
1886 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1892 self.unsubscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1893 .await
1894 }
1895
1896 pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1902 self.unsubscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1903 .await
1904 }
1905
1906 pub async fn unsubscribe_mark_prices(
1912 &self,
1913 instrument_id: InstrumentId,
1914 ) -> Result<(), OKXWsError> {
1915 self.unsubscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1916 .await
1917 }
1918
1919 pub async fn unsubscribe_index_prices(
1932 &self,
1933 instrument_id: InstrumentId,
1934 ) -> Result<(), OKXWsError> {
1935 let symbol = instrument_id.symbol.inner();
1936 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1937 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1938 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1939
1940 let _guard = self.index_pair_transition.lock().await;
1943
1944 let is_last = {
1945 let Some(mut count) = self.index_pair_subscribers.get_mut(&base_pair) else {
1946 return Ok(());
1948 };
1949 *count = count.saturating_sub(1);
1950 *count == 0
1951 };
1952
1953 if !is_last {
1954 return Ok(());
1955 }
1956
1957 self.index_pair_subscribers
1958 .remove_if(&base_pair, |_, count| *count == 0);
1959
1960 let arg = OKXSubscriptionArg {
1961 channel: OKXWsChannel::IndexTickers,
1962 inst_type: None,
1963 inst_family: None,
1964 inst_id: Some(base_pair),
1965 };
1966 self.unsubscribe(vec![arg]).await
1967 }
1968
1969 pub async fn unsubscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1975 let arg = OKXSubscriptionArg {
1976 channel: OKXWsChannel::OptionSummary,
1977 inst_type: None,
1978 inst_family: Some(inst_family),
1979 inst_id: None,
1980 };
1981 self.unsubscribe(vec![arg]).await
1982 }
1983
1984 pub async fn unsubscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1990 let arg = OKXSubscriptionArg {
1991 channel: OKXWsChannel::EventContractMarkets,
1992 inst_type: Some(OKXInstrumentType::Events),
1993 inst_family: None,
1994 inst_id: None,
1995 };
1996 self.unsubscribe(vec![arg]).await
1997 }
1998
1999 pub async fn unsubscribe_funding_rates(
2005 &self,
2006 instrument_id: InstrumentId,
2007 ) -> Result<(), OKXWsError> {
2008 self.unsubscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
2009 .await
2010 }
2011
2012 pub async fn unsubscribe_trades(
2018 &self,
2019 instrument_id: InstrumentId,
2020 aggregated: bool,
2021 ) -> Result<(), OKXWsError> {
2022 let channel = if aggregated {
2023 OKXWsChannel::TradesAll
2024 } else {
2025 OKXWsChannel::Trades
2026 };
2027 self.unsubscribe_inst_id(channel, instrument_id.symbol.inner())
2028 .await
2029 }
2030
2031 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
2037 let channel = bar_spec_as_okx_channel(bar_type.spec())
2038 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2039 self.unsubscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
2040 .await
2041 }
2042
2043 pub async fn subscribe_orders(
2049 &self,
2050 instrument_type: OKXInstrumentType,
2051 ) -> Result<(), OKXWsError> {
2052 let arg = OKXSubscriptionArg {
2053 channel: OKXWsChannel::Orders,
2054 inst_type: Some(instrument_type),
2055 inst_family: None,
2056 inst_id: None,
2057 };
2058 self.subscribe(vec![arg]).await
2059 }
2060
2061 pub async fn unsubscribe_orders(
2067 &self,
2068 instrument_type: OKXInstrumentType,
2069 ) -> Result<(), OKXWsError> {
2070 let arg = OKXSubscriptionArg {
2071 channel: OKXWsChannel::Orders,
2072 inst_type: Some(instrument_type),
2073 inst_family: None,
2074 inst_id: None,
2075 };
2076 self.unsubscribe(vec![arg]).await
2077 }
2078
2079 pub async fn subscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2085 let arg = OKXSubscriptionArg {
2086 channel: OKXWsChannel::SprdOrders,
2087 inst_type: None,
2088 inst_family: None,
2089 inst_id: None,
2090 };
2091 self.subscribe(vec![arg]).await
2092 }
2093
2094 pub async fn unsubscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2100 let arg = OKXSubscriptionArg {
2101 channel: OKXWsChannel::SprdOrders,
2102 inst_type: None,
2103 inst_family: None,
2104 inst_id: None,
2105 };
2106 self.unsubscribe(vec![arg]).await
2107 }
2108
2109 pub async fn subscribe_spread_quotes(
2115 &self,
2116 instrument_id: InstrumentId,
2117 ) -> Result<(), OKXWsError> {
2118 self.subscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2119 .await
2120 }
2121
2122 pub async fn subscribe_spread_book(
2128 &self,
2129 instrument_id: InstrumentId,
2130 ) -> Result<(), OKXWsError> {
2131 self.subscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2132 .await
2133 }
2134
2135 pub async fn subscribe_spread_trades(
2141 &self,
2142 instrument_id: InstrumentId,
2143 ) -> Result<(), OKXWsError> {
2144 self.subscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2145 .await
2146 }
2147
2148 pub async fn unsubscribe_spread_quotes(
2154 &self,
2155 instrument_id: InstrumentId,
2156 ) -> Result<(), OKXWsError> {
2157 self.unsubscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2158 .await
2159 }
2160
2161 pub async fn unsubscribe_spread_book(
2167 &self,
2168 instrument_id: InstrumentId,
2169 ) -> Result<(), OKXWsError> {
2170 self.unsubscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2171 .await
2172 }
2173
2174 pub async fn unsubscribe_spread_trades(
2180 &self,
2181 instrument_id: InstrumentId,
2182 ) -> Result<(), OKXWsError> {
2183 self.unsubscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2184 .await
2185 }
2186
2187 pub async fn subscribe_orders_algo(
2193 &self,
2194 instrument_type: OKXInstrumentType,
2195 ) -> Result<(), OKXWsError> {
2196 let arg = OKXSubscriptionArg {
2197 channel: OKXWsChannel::OrdersAlgo,
2198 inst_type: Some(instrument_type),
2199 inst_family: None,
2200 inst_id: None,
2201 };
2202 self.subscribe(vec![arg]).await
2203 }
2204
2205 pub async fn unsubscribe_orders_algo(
2211 &self,
2212 instrument_type: OKXInstrumentType,
2213 ) -> Result<(), OKXWsError> {
2214 let arg = OKXSubscriptionArg {
2215 channel: OKXWsChannel::OrdersAlgo,
2216 inst_type: Some(instrument_type),
2217 inst_family: None,
2218 inst_id: None,
2219 };
2220 self.unsubscribe(vec![arg]).await
2221 }
2222
2223 pub async fn subscribe_algo_advance(
2229 &self,
2230 instrument_type: OKXInstrumentType,
2231 ) -> Result<(), OKXWsError> {
2232 let arg = OKXSubscriptionArg {
2233 channel: OKXWsChannel::AlgoAdvance,
2234 inst_type: Some(instrument_type),
2235 inst_family: None,
2236 inst_id: None,
2237 };
2238 self.subscribe(vec![arg]).await
2239 }
2240
2241 pub async fn unsubscribe_algo_advance(
2247 &self,
2248 instrument_type: OKXInstrumentType,
2249 ) -> Result<(), OKXWsError> {
2250 let arg = OKXSubscriptionArg {
2251 channel: OKXWsChannel::AlgoAdvance,
2252 inst_type: Some(instrument_type),
2253 inst_family: None,
2254 inst_id: None,
2255 };
2256 self.unsubscribe(vec![arg]).await
2257 }
2258
2259 pub async fn subscribe_account(&self) -> Result<(), OKXWsError> {
2265 let arg = OKXSubscriptionArg {
2266 channel: OKXWsChannel::Account,
2267 inst_type: None,
2268 inst_family: None,
2269 inst_id: None,
2270 };
2271 self.subscribe(vec![arg]).await
2272 }
2273
2274 pub async fn unsubscribe_account(&self) -> Result<(), OKXWsError> {
2280 let arg = OKXSubscriptionArg {
2281 channel: OKXWsChannel::Account,
2282 inst_type: None,
2283 inst_family: None,
2284 inst_id: None,
2285 };
2286 self.unsubscribe(vec![arg]).await
2287 }
2288
2289 pub async fn subscribe_positions(
2299 &self,
2300 inst_type: OKXInstrumentType,
2301 ) -> Result<(), OKXWsError> {
2302 let arg = OKXSubscriptionArg {
2303 channel: OKXWsChannel::Positions,
2304 inst_type: Some(inst_type),
2305 inst_family: None,
2306 inst_id: None,
2307 };
2308 self.subscribe(vec![arg]).await
2309 }
2310
2311 pub async fn unsubscribe_positions(
2317 &self,
2318 inst_type: OKXInstrumentType,
2319 ) -> Result<(), OKXWsError> {
2320 let arg = OKXSubscriptionArg {
2321 channel: OKXWsChannel::Positions,
2322 inst_type: Some(inst_type),
2323 inst_family: None,
2324 inst_id: None,
2325 };
2326 self.unsubscribe(vec![arg]).await
2327 }
2328
2329 pub async fn subscribe_liquidation_warning(
2339 &self,
2340 instrument_type: OKXInstrumentType,
2341 ) -> Result<(), OKXWsError> {
2342 let arg = OKXSubscriptionArg {
2343 channel: OKXWsChannel::LiquidationWarning,
2344 inst_type: Some(instrument_type),
2345 inst_family: None,
2346 inst_id: None,
2347 };
2348 self.subscribe(vec![arg]).await
2349 }
2350
2351 pub async fn unsubscribe_liquidation_warning(
2357 &self,
2358 instrument_type: OKXInstrumentType,
2359 ) -> Result<(), OKXWsError> {
2360 let arg = OKXSubscriptionArg {
2361 channel: OKXWsChannel::LiquidationWarning,
2362 inst_type: Some(instrument_type),
2363 inst_family: None,
2364 inst_id: None,
2365 };
2366 self.unsubscribe(vec![arg]).await
2367 }
2368
2369 async fn ws_batch_place_orders(
2375 &self,
2376 args: Vec<Value>,
2377 client_order_ids: Vec<ClientOrderId>,
2378 ) -> Result<(), OKXWsError> {
2379 let request_id = self.generate_unique_request_id();
2380 let request = OKXWsRequest::<Value> {
2381 id: Some(request_id.clone()),
2382 op: super::enums::OKXWsOperation::BatchOrders,
2383 exp_time: None,
2384 args,
2385 };
2386
2387 let payload = serde_json::to_string(&request)
2388 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch orders: {e}")))?;
2389
2390 let cmd = HandlerCommand::Send {
2391 payload,
2392 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_ORDER.to_vec()),
2393 request_id: Some(request_id),
2394 client_order_ids,
2395 op: Some(super::enums::OKXWsOperation::BatchOrders),
2396 };
2397
2398 self.send_cmd(cmd).await
2399 }
2400
2401 async fn ws_batch_cancel_orders(
2407 &self,
2408 args: Vec<Value>,
2409 client_order_ids: Vec<ClientOrderId>,
2410 ) -> Result<(), OKXWsError> {
2411 let request_id = self.generate_unique_request_id();
2412 let request = OKXWsRequest::<Value> {
2413 id: Some(request_id.clone()),
2414 op: super::enums::OKXWsOperation::BatchCancelOrders,
2415 exp_time: None,
2416 args,
2417 };
2418
2419 let payload = serde_json::to_string(&request)
2420 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch cancel: {e}")))?;
2421
2422 let cmd = HandlerCommand::Send {
2423 payload,
2424 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_CANCEL.to_vec()),
2425 request_id: Some(request_id),
2426 client_order_ids,
2427 op: Some(super::enums::OKXWsOperation::BatchCancelOrders),
2428 };
2429
2430 self.send_cmd(cmd).await
2431 }
2432
2433 async fn ws_batch_amend_orders(
2439 &self,
2440 args: Vec<Value>,
2441 client_order_ids: Vec<ClientOrderId>,
2442 ) -> Result<(), OKXWsError> {
2443 let request_id = self.generate_unique_request_id();
2444 let request = OKXWsRequest::<Value> {
2445 id: Some(request_id.clone()),
2446 op: super::enums::OKXWsOperation::BatchAmendOrders,
2447 exp_time: None,
2448 args,
2449 };
2450
2451 let payload = serde_json::to_string(&request)
2452 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch amend: {e}")))?;
2453
2454 let cmd = HandlerCommand::Send {
2455 payload,
2456 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_AMEND.to_vec()),
2457 request_id: Some(request_id),
2458 client_order_ids,
2459 op: Some(super::enums::OKXWsOperation::BatchAmendOrders),
2460 };
2461
2462 self.send_cmd(cmd).await
2463 }
2464
2465 #[expect(clippy::too_many_arguments)]
2477 pub async fn submit_order(
2478 &self,
2479 trader_id: TraderId,
2480 strategy_id: StrategyId,
2481 instrument_id: InstrumentId,
2482 td_mode: OKXTradeMode,
2483 client_order_id: ClientOrderId,
2484 order_side: OrderSide,
2485 order_type: OrderType,
2486 quantity: Quantity,
2487 time_in_force: Option<TimeInForce>,
2488 price: Option<Price>,
2489 trigger_price: Option<Price>,
2490 post_only: Option<bool>,
2491 reduce_only: Option<bool>,
2492 quote_quantity: Option<bool>,
2493 position_side: Option<PositionSide>,
2494 attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
2495 px_usd: Option<String>,
2496 px_vol: Option<String>,
2497 outcome: Option<String>,
2498 slippage_pct: Option<String>,
2499 rpi: Option<bool>,
2500 rpi_taker_access: Option<bool>,
2501 rpi_px_round: Option<bool>,
2502 ) -> Result<(), OKXWsError> {
2503 let rpi = rpi.unwrap_or(false);
2504
2505 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
2506 return Err(OKXWsError::ClientError(format!(
2507 "Unsupported order type: {order_type:?}",
2508 )));
2509 }
2510
2511 if let Some(tif) = time_in_force
2512 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
2513 {
2514 return Err(OKXWsError::ClientError(format!(
2515 "Unsupported time in force: {tif:?}",
2516 )));
2517 }
2518
2519 let mut builder = WsPostOrderParamsBuilder::default();
2520
2521 let inst_id_code = self
2522 .get_inst_id_code(&instrument_id.symbol.inner())
2523 .ok_or_else(|| {
2524 OKXWsError::ClientError(format!(
2525 "No instIdCode cached for {instrument_id}, cannot submit order"
2526 ))
2527 })?;
2528 builder.inst_id_code(inst_id_code);
2529
2530 builder.td_mode(td_mode);
2531 builder.cl_ord_id(client_order_id.as_str());
2532
2533 let (instrument_type, quote_currency) = {
2534 let instruments = self.instruments_cache.load();
2535 let symbol = instrument_id.symbol.inner();
2536 let instrument = instruments.get(&symbol).ok_or_else(|| {
2537 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2538 })?;
2539 let instrument_type = okx_instrument_type(instrument)
2540 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2541 (instrument_type, instrument.quote_currency())
2542 };
2543
2544 if instrument_type == OKXInstrumentType::Option
2546 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
2547 {
2548 return Err(OKXWsError::ClientError(
2549 "Market orders are not supported for OKX options, use Limit orders instead"
2550 .to_string(),
2551 ));
2552 }
2553
2554 match instrument_type {
2555 OKXInstrumentType::Spot | OKXInstrumentType::Margin => {
2556 builder.ccy(quote_currency.to_string());
2558 }
2559 OKXInstrumentType::Swap | OKXInstrumentType::Futures => {
2560 builder.ccy(quote_currency.to_string());
2562
2563 if position_side.is_none() {
2566 builder.pos_side(OKXPositionSide::Net);
2567 }
2568 }
2569 OKXInstrumentType::Option => {
2570 builder.ccy(quote_currency.to_string());
2571
2572 if position_side.is_none() {
2573 builder.pos_side(OKXPositionSide::Net);
2574 }
2575 }
2576 OKXInstrumentType::Events => {}
2577 _ => {
2578 builder.ccy(quote_currency.to_string());
2579
2580 if position_side.is_none() {
2581 builder.pos_side(OKXPositionSide::Net);
2582 }
2583 }
2584 }
2585
2586 if let Some(reduce_only) = okx_reduce_only_wire_value(
2587 instrument_type,
2588 td_mode,
2589 order_side,
2590 position_side,
2591 reduce_only,
2592 )
2593 .map_err(OKXWsError::ClientError)?
2594 {
2595 builder.reduce_only(reduce_only);
2596 }
2597
2598 if let Some(attach_algo_ords) = attach_algo_ords {
2599 builder.attach_algo_ords(attach_algo_ords);
2600 }
2601
2602 if instrument_type == OKXInstrumentType::Spot
2609 && order_type == OrderType::Market
2610 && td_mode == OKXTradeMode::Cash
2611 {
2612 match quote_quantity {
2613 Some(true) => {
2614 builder.tgt_ccy(OKXTargetCurrency::QuoteCcy);
2615 }
2616 Some(false) if order_side == OrderSide::Buy => {
2618 builder.tgt_ccy(OKXTargetCurrency::BaseCcy);
2619 }
2620 Some(false) | None => {}
2622 }
2623 }
2624
2625 builder.side(order_side);
2626
2627 if let Some(pos_side) = position_side {
2628 builder.pos_side(pos_side);
2629 }
2630
2631 if rpi && order_type != OrderType::Limit {
2635 return Err(OKXWsError::ClientError(
2636 "OKX RPI orders require a limit order".to_string(),
2637 ));
2638 }
2639
2640 let (okx_ord_type, price) = if rpi {
2641 (OKXOrderType::Rpi, price)
2642 } else if post_only.unwrap_or(false) {
2643 (OKXOrderType::PostOnly, price)
2644 } else if let Some(tif) = time_in_force {
2645 match (order_type, tif) {
2646 (OrderType::Market, TimeInForce::Fok) => {
2647 return Err(OKXWsError::ClientError(
2648 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string()
2649 ));
2650 }
2651 (OrderType::Market, TimeInForce::Ioc) => {
2652 if matches!(
2654 instrument_type,
2655 OKXInstrumentType::Spot | OKXInstrumentType::Option
2656 ) {
2657 (OKXOrderType::Market, price)
2658 } else {
2659 (OKXOrderType::OptimalLimitIoc, price)
2660 }
2661 }
2662 (OrderType::Limit, TimeInForce::Fok) => {
2663 if instrument_type == OKXInstrumentType::Option {
2665 (OKXOrderType::OpFok, price)
2666 } else {
2667 (OKXOrderType::Fok, price)
2668 }
2669 }
2670 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
2671 _ => (OKXOrderType::from(order_type), price),
2672 }
2673 } else {
2674 (OKXOrderType::from(order_type), price)
2675 };
2676
2677 log::debug!(
2678 "Order type mapping: order_type={order_type:?}, time_in_force={time_in_force:?}, post_only={post_only:?} -> okx_ord_type={okx_ord_type:?}"
2679 );
2680
2681 if instrument_type == OKXInstrumentType::Events && outcome.is_none() {
2682 return Err(OKXWsError::ClientError(
2683 "OKX event contract orders require `outcome`".to_string(),
2684 ));
2685 }
2686
2687 if let Some(outcome) = outcome {
2688 builder.outcome(outcome);
2689 }
2690
2691 if let Some(slippage) = slippage_pct {
2692 builder.slippage_pct(slippage);
2693 }
2694
2695 if let Some(rpi_taker_access) = rpi_taker_access {
2696 builder.rpi_taker_access(rpi_taker_access);
2697 }
2698
2699 if let Some(rpi_px_round) = rpi_px_round {
2700 builder.rpi_px_round(rpi_px_round);
2701 }
2702
2703 if let Some(trade_quote_ccy) =
2704 self.resolve_spot_trade_quote_ccy(instrument_type, instrument_id.symbol.inner())?
2705 {
2706 builder.trade_quote_ccy(trade_quote_ccy);
2707 }
2708
2709 builder.ord_type(okx_ord_type);
2710 builder.sz(quantity.to_string());
2711
2712 if let Some(usd) = px_usd {
2714 builder.px_usd(usd);
2715 } else if let Some(vol) = px_vol {
2716 builder.px_vol(vol);
2717 } else if let Some(tp) = trigger_price {
2718 builder.px(tp.to_string());
2719 } else if let Some(p) = price {
2720 builder.px(p.to_string());
2721 }
2722
2723 builder.tag(OKX_NAUTILUS_BROKER_ID);
2724
2725 let params = builder
2726 .build()
2727 .map_err(|e| OKXWsError::ClientError(format!("Build order params error: {e}")))?;
2728
2729 let request_id = self.generate_unique_request_id();
2730 let request = OKXWsRequest {
2731 id: Some(request_id.clone()),
2732 op: super::enums::OKXWsOperation::Order,
2733 exp_time: None,
2734 args: vec![params],
2735 };
2736
2737 let payload = serde_json::to_string(&request)
2738 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize order: {e}")))?;
2739
2740 let cl_ord_key = client_order_id.to_string();
2741 self.pending_orders.insert(
2742 cl_ord_key.clone(),
2743 PendingOrderInfo {
2744 trader_id,
2745 strategy_id,
2746 instrument_id,
2747 },
2748 );
2749
2750 let cmd = HandlerCommand::Send {
2751 payload,
2752 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ORDER.to_vec()),
2753 request_id: Some(request_id),
2754 client_order_ids: vec![client_order_id],
2755 op: Some(super::enums::OKXWsOperation::Order),
2756 };
2757
2758 let result = self.send_cmd(cmd).await;
2759
2760 if result.is_err() {
2761 self.pending_orders.remove(&cl_ord_key);
2762 }
2763
2764 result
2765 }
2766
2767 #[expect(clippy::too_many_arguments)]
2783 pub async fn modify_order(
2784 &self,
2785 trader_id: TraderId,
2786 strategy_id: StrategyId,
2787 instrument_id: InstrumentId,
2788 client_order_id: Option<ClientOrderId>,
2789 price: Option<Price>,
2790 quantity: Option<Quantity>,
2791 venue_order_id: Option<VenueOrderId>,
2792 new_px_usd: Option<String>,
2793 new_px_vol: Option<String>,
2794 rpi_taker_access: Option<bool>,
2795 rpi_px_round: Option<bool>,
2796 ) -> Result<(), OKXWsError> {
2797 let mut builder = WsAmendOrderParamsBuilder::default();
2798
2799 let inst_id_code = self
2800 .get_inst_id_code(&instrument_id.symbol.inner())
2801 .ok_or_else(|| {
2802 OKXWsError::ClientError(format!(
2803 "No instIdCode cached for {instrument_id}, cannot amend order"
2804 ))
2805 })?;
2806 builder.inst_id_code(inst_id_code);
2807
2808 if let Some(venue_order_id) = venue_order_id {
2809 builder.ord_id(venue_order_id.as_str());
2810 }
2811
2812 let cl_ord_key = client_order_id.map(|id| id.to_string());
2813
2814 if let Some(client_order_id) = client_order_id {
2815 builder.cl_ord_id(client_order_id.as_str());
2816 self.pending_amends.insert(
2817 client_order_id.to_string(),
2818 PendingOrderInfo {
2819 trader_id,
2820 strategy_id,
2821 instrument_id,
2822 },
2823 );
2824 }
2825
2826 if let Some(usd) = new_px_usd {
2828 builder.new_px_usd(usd);
2829 } else if let Some(vol) = new_px_vol {
2830 builder.new_px_vol(vol);
2831 } else if let Some(price) = price {
2832 builder.new_px(price.to_string());
2833 }
2834
2835 if let Some(quantity) = quantity {
2836 builder.new_sz(quantity.to_string());
2837 }
2838
2839 if let Some(rpi_taker_access) = rpi_taker_access {
2840 builder.rpi_taker_access(rpi_taker_access);
2841 }
2842
2843 if let Some(rpi_px_round) = rpi_px_round {
2844 builder.rpi_px_round(rpi_px_round);
2845 }
2846
2847 let params = builder
2848 .build()
2849 .map_err(|e| OKXWsError::ClientError(format!("Build amend params error: {e}")))?;
2850
2851 let request_id = self.generate_unique_request_id();
2852 let request = OKXWsRequest {
2853 id: Some(request_id.clone()),
2854 op: super::enums::OKXWsOperation::AmendOrder,
2855 exp_time: None,
2856 args: vec![params],
2857 };
2858
2859 let payload = serde_json::to_string(&request)
2860 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize amend: {e}")))?;
2861
2862 let cmd = HandlerCommand::Send {
2863 payload,
2864 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_AMEND.to_vec()),
2865 request_id: Some(request_id),
2866 client_order_ids: client_order_id.into_iter().collect(),
2867 op: Some(super::enums::OKXWsOperation::AmendOrder),
2868 };
2869
2870 let result = self.send_cmd(cmd).await;
2871
2872 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2873 self.pending_amends.remove(key);
2874 }
2875
2876 result
2877 }
2878
2879 pub async fn cancel_order(
2890 &self,
2891 trader_id: TraderId,
2892 strategy_id: StrategyId,
2893 instrument_id: InstrumentId,
2894 client_order_id: Option<ClientOrderId>,
2895 venue_order_id: Option<VenueOrderId>,
2896 ) -> Result<(), OKXWsError> {
2897 let mut builder = WsCancelOrderParamsBuilder::default();
2898
2899 let inst_id_code = self
2900 .get_inst_id_code(&instrument_id.symbol.inner())
2901 .ok_or_else(|| {
2902 OKXWsError::ClientError(format!(
2903 "No instIdCode cached for {instrument_id}, cannot cancel order"
2904 ))
2905 })?;
2906 builder.inst_id_code(inst_id_code);
2907
2908 if let Some(venue_order_id) = venue_order_id {
2909 builder.ord_id(venue_order_id.as_str());
2910 }
2911
2912 let cl_ord_key = client_order_id.map(|id| id.to_string());
2913
2914 if let Some(client_order_id) = client_order_id {
2915 builder.cl_ord_id(client_order_id.as_str());
2916 self.pending_cancels.insert(
2917 client_order_id.to_string(),
2918 PendingOrderInfo {
2919 trader_id,
2920 strategy_id,
2921 instrument_id,
2922 },
2923 );
2924 }
2925
2926 let params = builder
2927 .build()
2928 .map_err(|e| OKXWsError::ClientError(format!("Build cancel params error: {e}")))?;
2929
2930 let request_id = self.generate_unique_request_id();
2931 let request = OKXWsRequest {
2932 id: Some(request_id.clone()),
2933 op: super::enums::OKXWsOperation::CancelOrder,
2934 exp_time: None,
2935 args: vec![params],
2936 };
2937
2938 let payload = serde_json::to_string(&request)
2939 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel: {e}")))?;
2940
2941 let cmd = HandlerCommand::Send {
2942 payload,
2943 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_CANCEL.to_vec()),
2944 request_id: Some(request_id),
2945 client_order_ids: client_order_id.into_iter().collect(),
2946 op: Some(super::enums::OKXWsOperation::CancelOrder),
2947 };
2948
2949 let result = self.send_cmd(cmd).await;
2950
2951 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2952 self.pending_cancels.remove(key);
2953 }
2954
2955 result
2956 }
2957
2958 pub async fn mass_cancel_orders(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
2968 let (inst_type, inst_family) = {
2969 let instrument = self
2970 .instruments_cache
2971 .get_cloned(&instrument_id.symbol.inner())
2972 .ok_or_else(|| {
2973 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2974 })?;
2975
2976 let inst_type = okx_instrument_type(&instrument)
2977 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2978
2979 let symbol = instrument.symbol().inner();
2980 let inst_family = match &instrument {
2981 InstrumentAny::CurrencyPair(_) => symbol.as_str().to_string(),
2982 InstrumentAny::CryptoPerpetual(_) => symbol
2983 .as_str()
2984 .strip_suffix("-SWAP")
2985 .unwrap_or(symbol.as_str())
2986 .to_string(),
2987 InstrumentAny::CryptoFuture(_) => {
2988 let s = symbol.as_str();
2989 if let Some(idx) = s.rfind('-') {
2990 s[..idx].to_string()
2991 } else {
2992 s.to_string()
2993 }
2994 }
2995 _ => {
2996 return Err(OKXWsError::ClientError(
2997 "Unsupported instrument type for mass cancel".to_string(),
2998 ));
2999 }
3000 };
3001
3002 (inst_type, inst_family)
3003 };
3004
3005 let params = WsMassCancelParams {
3006 inst_type,
3007 inst_family: Ustr::from(&inst_family),
3008 };
3009
3010 let request_id = self.generate_unique_request_id();
3011 let request = OKXWsRequest {
3012 id: Some(request_id.clone()),
3013 op: super::enums::OKXWsOperation::MassCancel,
3014 exp_time: None,
3015 args: vec![
3016 serde_json::to_value(params).map_err(|e| OKXWsError::JsonError(e.to_string()))?,
3017 ],
3018 };
3019
3020 let payload = serde_json::to_string(&request)
3021 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize mass cancel: {e}")))?;
3022
3023 let cmd = HandlerCommand::Send {
3024 payload,
3025 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_MASS_CANCEL.to_vec()),
3026 request_id: Some(request_id),
3027 client_order_ids: Vec::new(),
3028 op: Some(super::enums::OKXWsOperation::MassCancel),
3029 };
3030
3031 self.send_cmd(cmd).await
3032 }
3033
3034 #[expect(clippy::type_complexity)]
3041 pub async fn batch_submit_orders(
3042 &self,
3043 orders: Vec<(
3044 OKXInstrumentType,
3045 InstrumentId,
3046 OKXTradeMode,
3047 ClientOrderId,
3048 OrderSide,
3049 Option<PositionSide>,
3050 OrderType,
3051 Quantity,
3052 Option<Price>,
3053 Option<Price>,
3054 Option<bool>,
3055 Option<bool>,
3056 Option<String>,
3057 Option<bool>,
3058 Option<bool>,
3059 Option<bool>,
3060 )>,
3061 ) -> Result<(), OKXWsError> {
3062 let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.3).collect();
3063 let args: Vec<Value> = {
3064 let mut args = Vec::with_capacity(orders.len());
3065 let inst_id_codes = self.inst_id_code_cache.load();
3066 let instruments = self.instruments_cache.load();
3067
3068 for (
3069 inst_type,
3070 inst_id,
3071 td_mode,
3072 cl_ord_id,
3073 ord_side,
3074 pos_side,
3075 ord_type,
3076 qty,
3077 pr,
3078 tp,
3079 post_only,
3080 reduce_only,
3081 outcome,
3082 rpi,
3083 rpi_taker_access,
3084 rpi_px_round,
3085 ) in orders
3086 {
3087 let rpi = rpi.unwrap_or(false);
3088 let mut builder = WsPostOrderParamsBuilder::default();
3089
3090 let (inst_id_symbol, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3091 &inst_id_codes,
3092 &inst_id,
3093 "submit",
3094 )?;
3095 builder.inst_id_code(inst_id_code);
3096
3097 builder.td_mode(td_mode);
3098 builder.cl_ord_id(cl_ord_id.as_str());
3099 builder.side(ord_side);
3100
3101 if inst_type != OKXInstrumentType::Events
3102 && let Some(instrument) = instruments.get(&inst_id_symbol)
3103 {
3104 builder.ccy(instrument.quote_currency().to_string());
3105 }
3106
3107 if let Some(ps) = pos_side {
3108 builder.pos_side(OKXPositionSide::from(ps));
3109 } else if matches!(
3110 inst_type,
3111 OKXInstrumentType::Swap
3112 | OKXInstrumentType::Futures
3113 | OKXInstrumentType::Option
3114 ) {
3115 builder.pos_side(OKXPositionSide::Net);
3116 }
3117
3118 if rpi && ord_type != OrderType::Limit {
3119 return Err(OKXWsError::ClientError(
3120 "OKX RPI batch orders require limit orders".to_string(),
3121 ));
3122 }
3123
3124 let okx_ord_type = if rpi {
3125 OKXOrderType::Rpi
3126 } else if post_only.unwrap_or(false) {
3127 OKXOrderType::PostOnly
3128 } else {
3129 match ord_type {
3130 OrderType::Market => OKXOrderType::Market,
3131 OrderType::Limit => OKXOrderType::Limit,
3132 OrderType::MarketToLimit => OKXOrderType::Ioc,
3133 _ => {
3134 return Err(OKXWsError::ClientError(format!(
3135 "Unsupported order type for batch submit: {ord_type:?}"
3136 )));
3137 }
3138 }
3139 };
3140
3141 builder.ord_type(okx_ord_type);
3142 builder.sz(qty.to_string());
3143
3144 if let Some(p) = pr {
3145 builder.px(p.to_string());
3146 } else if let Some(p) = tp {
3147 builder.px(p.to_string());
3148 }
3149
3150 if let Some(reduce_only) =
3151 okx_reduce_only_wire_value(inst_type, td_mode, ord_side, pos_side, reduce_only)
3152 .map_err(OKXWsError::ClientError)?
3153 {
3154 builder.reduce_only(reduce_only);
3155 }
3156
3157 if inst_type == OKXInstrumentType::Events && outcome.is_none() {
3158 return Err(OKXWsError::ClientError(
3159 "OKX event contract orders require `outcome`".to_string(),
3160 ));
3161 }
3162
3163 if let Some(outcome) = outcome {
3164 builder.outcome(outcome);
3165 }
3166
3167 if let Some(rpi_taker_access) = rpi_taker_access {
3168 builder.rpi_taker_access(rpi_taker_access);
3169 }
3170
3171 if let Some(rpi_px_round) = rpi_px_round {
3172 builder.rpi_px_round(rpi_px_round);
3173 }
3174
3175 if let Some(trade_quote_ccy) =
3176 self.resolve_spot_trade_quote_ccy(inst_type, inst_id_symbol)?
3177 {
3178 builder.trade_quote_ccy(trade_quote_ccy);
3179 }
3180
3181 builder.tag(OKX_NAUTILUS_BROKER_ID);
3182
3183 let params = builder.build().map_err(|e| {
3184 OKXWsError::ClientError(format!("Build order params error: {e}"))
3185 })?;
3186 let val = serde_json::to_value(params)
3187 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3188 args.push(val);
3189 }
3190 args
3191 };
3192
3193 self.ws_batch_place_orders(args, client_order_ids).await
3194 }
3195
3196 #[expect(clippy::type_complexity)]
3203 pub async fn batch_modify_orders(
3204 &self,
3205 orders: Vec<(
3206 OKXInstrumentType,
3207 InstrumentId,
3208 ClientOrderId,
3209 Option<String>,
3210 Option<Price>,
3211 Option<Quantity>,
3212 Option<bool>,
3213 Option<bool>,
3214 )>,
3215 ) -> Result<(), OKXWsError> {
3216 let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.2).collect();
3217 let args: Vec<Value> = {
3218 let mut args = Vec::with_capacity(orders.len());
3219 let inst_id_codes = self.inst_id_code_cache.load();
3220
3221 for (
3222 _inst_type,
3223 inst_id,
3224 cl_ord_id,
3225 request_id,
3226 pr,
3227 sz,
3228 rpi_taker_access,
3229 rpi_px_round,
3230 ) in orders
3231 {
3232 let mut builder = WsAmendOrderParamsBuilder::default();
3233
3234 let (_, inst_id_code) =
3235 Self::inst_id_symbol_and_code_from_snapshot(&inst_id_codes, &inst_id, "amend")?;
3236 builder.inst_id_code(inst_id_code);
3237
3238 builder.cl_ord_id(cl_ord_id.as_str());
3239
3240 if let Some(request_id) = request_id {
3241 builder.req_id(request_id);
3242 }
3243
3244 if let Some(p) = pr {
3245 builder.new_px(p.to_string());
3246 }
3247
3248 if let Some(q) = sz {
3249 builder.new_sz(q.to_string());
3250 }
3251
3252 if let Some(rpi_taker_access) = rpi_taker_access {
3253 builder.rpi_taker_access(rpi_taker_access);
3254 }
3255
3256 if let Some(rpi_px_round) = rpi_px_round {
3257 builder.rpi_px_round(rpi_px_round);
3258 }
3259
3260 let params = builder.build().map_err(|e| {
3261 OKXWsError::ClientError(format!("Build amend batch params error: {e}"))
3262 })?;
3263 let val = serde_json::to_value(params)
3264 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3265 args.push(val);
3266 }
3267 args
3268 };
3269
3270 self.ws_batch_amend_orders(args, client_order_ids).await
3271 }
3272
3273 pub async fn batch_cancel_orders(
3286 &self,
3287 orders: Vec<(InstrumentId, Option<ClientOrderId>, Option<VenueOrderId>)>,
3288 ) -> Result<(), OKXWsError> {
3289 let client_order_ids: Vec<ClientOrderId> = orders
3290 .iter()
3291 .filter_map(|(_, cl_ord_id, _)| *cl_ord_id)
3292 .collect();
3293 let args: Vec<Value> = {
3294 let mut args = Vec::with_capacity(orders.len());
3295 let inst_id_codes = self.inst_id_code_cache.load();
3296
3297 for (inst_id, cl_ord_id, ord_id) in orders {
3298 let mut builder = WsCancelOrderParamsBuilder::default();
3299
3300 let (_, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3301 &inst_id_codes,
3302 &inst_id,
3303 "cancel",
3304 )?;
3305 builder.inst_id_code(inst_id_code);
3306
3307 if let Some(c) = cl_ord_id {
3308 builder.cl_ord_id(c.as_str());
3309 }
3310
3311 if let Some(o) = ord_id {
3312 builder.ord_id(o.as_str());
3313 }
3314
3315 let params = builder.build().map_err(|e| {
3316 OKXWsError::ClientError(format!("Build cancel batch params error: {e}"))
3317 })?;
3318 let val = serde_json::to_value(params)
3319 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3320 args.push(val);
3321 }
3322 args
3323 };
3324
3325 self.ws_batch_cancel_orders(args, client_order_ids).await
3326 }
3327
3328 #[expect(clippy::too_many_arguments)]
3339 pub async fn submit_algo_order(
3340 &self,
3341 _trader_id: TraderId,
3342 _strategy_id: StrategyId,
3343 instrument_id: InstrumentId,
3344 td_mode: OKXTradeMode,
3345 client_order_id: ClientOrderId,
3346 order_side: OrderSide,
3347 order_type: OrderType,
3348 quantity: Quantity,
3349 trigger_price: Option<Price>,
3350 trigger_type: Option<TriggerType>,
3351 limit_price: Option<Price>,
3352 reduce_only: Option<bool>,
3353 callback_ratio: Option<String>,
3354 callback_spread: Option<String>,
3355 activation_price: Option<Price>,
3356 ) -> Result<(), OKXWsError> {
3357 if !is_conditional_order(order_type) {
3358 return Err(OKXWsError::ClientError(format!(
3359 "Order type {order_type:?} is not a conditional order"
3360 )));
3361 }
3362
3363 let mut builder = WsPostAlgoOrderParamsBuilder::default();
3364
3365 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
3366 return Err(OKXWsError::ClientError(
3367 "Invalid order side for OKX".to_string(),
3368 ));
3369 }
3370
3371 let inst_id_code = self
3372 .get_inst_id_code(&instrument_id.symbol.inner())
3373 .ok_or_else(|| {
3374 OKXWsError::ClientError(format!(
3375 "No instIdCode cached for {instrument_id}, cannot submit algo order"
3376 ))
3377 })?;
3378 builder.inst_id_code(inst_id_code);
3379
3380 builder.td_mode(td_mode);
3381 builder.cl_ord_id(client_order_id.as_str());
3382 builder.side(order_side);
3383 builder.ord_type(
3384 conditional_order_to_algo_type(order_type)
3385 .map_err(|e| OKXWsError::ClientError(e.to_string()))?,
3386 );
3387 builder.sz(quantity.to_string());
3388
3389 if let Some(tp) = trigger_price {
3390 builder.trigger_px(tp.to_string());
3391 }
3392
3393 let okx_trigger_type = trigger_type.map_or(OKXTriggerType::Last, Into::into);
3395 builder.trigger_px_type(okx_trigger_type);
3396
3397 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
3399 && let Some(price) = limit_price
3400 {
3401 builder.order_px(price.to_string());
3402 }
3403
3404 if let Some(reduce) = reduce_only {
3405 builder.reduce_only(reduce);
3406 }
3407
3408 if let Some(ratio) = callback_ratio {
3409 builder.callback_ratio(ratio);
3410 }
3411
3412 if let Some(spread) = callback_spread {
3413 builder.callback_spread(spread);
3414 }
3415
3416 if let Some(active) = activation_price {
3417 builder.active_px(active.to_string());
3418 }
3419
3420 builder.tag(OKX_NAUTILUS_BROKER_ID);
3421
3422 let params = builder
3423 .build()
3424 .map_err(|e| OKXWsError::ClientError(format!("Build algo order params error: {e}")))?;
3425
3426 let request_id = self.generate_unique_request_id();
3427 let request = OKXWsRequest {
3428 id: Some(request_id.clone()),
3429 op: super::enums::OKXWsOperation::OrderAlgo,
3430 exp_time: None,
3431 args: vec![params],
3432 };
3433
3434 let payload = serde_json::to_string(&request)
3435 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize algo order: {e}")))?;
3436
3437 let cmd = HandlerCommand::Send {
3438 payload,
3439 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_ORDER.to_vec()),
3440 request_id: Some(request_id),
3441 client_order_ids: vec![client_order_id],
3442 op: Some(super::enums::OKXWsOperation::OrderAlgo),
3443 };
3444
3445 self.send_cmd(cmd).await
3446 }
3447
3448 pub async fn cancel_algo_order(
3459 &self,
3460 _trader_id: TraderId,
3461 _strategy_id: StrategyId,
3462 instrument_id: InstrumentId,
3463 client_order_id: Option<ClientOrderId>,
3464 algo_order_id: Option<String>,
3465 ) -> Result<(), OKXWsError> {
3466 let mut builder = super::messages::WsCancelAlgoOrderParamsBuilder::default();
3467
3468 let inst_id_code = self
3469 .get_inst_id_code(&instrument_id.symbol.inner())
3470 .ok_or_else(|| {
3471 OKXWsError::ClientError(format!(
3472 "No instIdCode cached for {instrument_id}, cannot cancel algo order"
3473 ))
3474 })?;
3475 builder.inst_id_code(inst_id_code);
3476
3477 if let Some(algo_id) = algo_order_id {
3478 builder.algo_id(algo_id);
3479 }
3480
3481 if let Some(cl_ord_id) = client_order_id {
3482 builder.algo_cl_ord_id(cl_ord_id.to_string());
3483 }
3484
3485 let params = builder
3486 .build()
3487 .map_err(|e| OKXWsError::ClientError(format!("Build cancel algo params error: {e}")))?;
3488
3489 let request_id = self.generate_unique_request_id();
3490 let request = OKXWsRequest {
3491 id: Some(request_id.clone()),
3492 op: super::enums::OKXWsOperation::CancelAlgos,
3493 exp_time: None,
3494 args: vec![params],
3495 };
3496
3497 let payload = serde_json::to_string(&request)
3498 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel algo: {e}")))?;
3499
3500 let cmd = HandlerCommand::Send {
3501 payload,
3502 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_CANCEL.to_vec()),
3503 request_id: Some(request_id),
3504 client_order_ids: client_order_id.into_iter().collect(),
3505 op: Some(super::enums::OKXWsOperation::CancelAlgos),
3506 };
3507
3508 self.send_cmd(cmd).await
3509 }
3510
3511 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), OKXWsError> {
3513 self.cmd_tx
3514 .read()
3515 .await
3516 .send(cmd)
3517 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))
3518 }
3519}
3520
3521fn authentication_timestamp(now: UnixNanos) -> String {
3522 now.as_seconds().to_string()
3523}
3524
3525fn subscription_args(
3526 subscriptions_inst_type: &DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>,
3527 subscriptions_inst_family: &DashMap<OKXWsChannel, AHashSet<Ustr>>,
3528 subscriptions_inst_id: &DashMap<OKXWsChannel, AHashSet<Ustr>>,
3529 subscriptions_bare: &DashMap<OKXWsChannel, bool>,
3530) -> Vec<OKXSubscriptionArg> {
3531 let mut args = Vec::new();
3532
3533 for entry in subscriptions_inst_type {
3534 let (channel, inst_types) = entry.pair();
3535 for inst_type in inst_types {
3536 args.push(OKXSubscriptionArg {
3537 channel: channel.clone(),
3538 inst_type: Some(*inst_type),
3539 inst_family: None,
3540 inst_id: None,
3541 });
3542 }
3543 }
3544
3545 for entry in subscriptions_inst_family {
3546 let (channel, inst_families) = entry.pair();
3547 for inst_family in inst_families {
3548 args.push(OKXSubscriptionArg {
3549 channel: channel.clone(),
3550 inst_type: None,
3551 inst_family: Some(*inst_family),
3552 inst_id: None,
3553 });
3554 }
3555 }
3556
3557 for entry in subscriptions_inst_id {
3558 let (channel, inst_ids) = entry.pair();
3559 for inst_id in inst_ids {
3560 args.push(OKXSubscriptionArg {
3561 channel: channel.clone(),
3562 inst_type: None,
3563 inst_family: None,
3564 inst_id: Some(*inst_id),
3565 });
3566 }
3567 }
3568
3569 for entry in subscriptions_bare {
3570 args.push(OKXSubscriptionArg {
3571 channel: entry.key().clone(),
3572 inst_type: None,
3573 inst_family: None,
3574 inst_id: None,
3575 });
3576 }
3577
3578 args.sort_unstable_by_key(topic_from_subscription_arg);
3579 args
3580}
3581
3582fn subscription_arg_batches(
3583 args: &[OKXSubscriptionArg],
3584) -> impl Iterator<Item = &[OKXSubscriptionArg]> {
3585 args.chunks(OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE)
3586}
3587
3588fn auth_attempt_superseded(error: &Error) -> bool {
3589 error.to_string().contains("superseded")
3590}
3591
3592fn reconnect_auth_retry_delay(attempt: u32) -> Duration {
3593 let shift = attempt.saturating_sub(1).min(31);
3594 let factor = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
3595
3596 RECONNECT_AUTH_RETRY_INITIAL
3597 .saturating_mul(factor)
3598 .min(RECONNECT_AUTH_RETRY_MAX)
3599}
3600
3601async fn authenticate_session(
3602 credential: &Credential,
3603 auth_tracker: &AuthTracker,
3604 cmd_tx: &tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
3605 clock: &'static AtomicTime,
3606 auth_timeout_secs: u64,
3607) -> Result<(), Error> {
3608 let rx = auth_tracker.begin();
3609 let timestamp = authentication_timestamp(clock.get_time_ns());
3610 let signature = credential.sign(×tamp, "GET", "/users/self/verify", "");
3611
3612 let auth_message = OKXAuthentication {
3613 op: "login",
3614 args: vec![OKXAuthenticationArg {
3615 api_key: SecretString::from(credential.api_key()),
3616 passphrase: SecretString::from(credential.api_passphrase()),
3617 timestamp,
3618 sign: SecretString::from(signature),
3619 }],
3620 };
3621
3622 let payload = serde_json::to_string(&auth_message)
3623 .map(SecretString::from)
3624 .map_err(|e| {
3625 Error::Io(std::io::Error::other(format!(
3626 "Failed to serialize auth message: {e}"
3627 )))
3628 })?;
3629
3630 cmd_tx
3631 .read()
3632 .await
3633 .send(HandlerCommand::Authenticate { payload })
3634 .map_err(|e| {
3635 Error::Io(std::io::Error::other(format!(
3636 "Failed to send authenticate command: {e}"
3637 )))
3638 })?;
3639
3640 match auth_tracker
3641 .wait_for_result::<OKXWsError>(Duration::from_secs(auth_timeout_secs), rx)
3642 .await
3643 {
3644 Ok(()) => {
3645 log::debug!("WebSocket authenticated");
3646 Ok(())
3647 }
3648 Err(e) => {
3649 let auth_error = Error::Io(std::io::Error::other(e.to_string()));
3650 if !auth_attempt_superseded(&auth_error) {
3651 log::error!("WebSocket authentication failed: error={e}");
3652 }
3653
3654 Err(auth_error)
3655 }
3656 }
3657}
3658
3659#[allow(
3660 clippy::too_many_arguments,
3661 reason = "retry loop needs the session pieces without cloning the websocket client"
3662)]
3663async fn retry_reconnect_authentication(
3664 credential: Credential,
3665 auth_tracker: AuthTracker,
3666 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
3667 clock: &'static AtomicTime,
3668 auth_timeout_secs: u64,
3669 signal: Arc<AtomicBool>,
3670 abort: CancellationToken,
3671 generation_flag: Arc<AtomicU64>,
3672 generation: u64,
3673) {
3674 let mut attempt = 0u32;
3675
3676 loop {
3677 if signal.load(Ordering::Acquire) || abort.is_cancelled() {
3678 break;
3679 }
3680
3681 if generation_flag.load(Ordering::Acquire) != generation {
3682 break;
3683 }
3684
3685 if cmd_tx.read().await.is_closed() {
3686 break;
3687 }
3688
3689 if auth_tracker.is_authenticated() {
3690 break;
3691 }
3692
3693 attempt = attempt.saturating_add(1);
3694
3695 match authenticate_session(
3696 &credential,
3697 &auth_tracker,
3698 &cmd_tx,
3699 clock,
3700 auth_timeout_secs,
3701 )
3702 .await
3703 {
3704 Ok(()) => {
3705 log::debug!("Re-authenticated after reconnection");
3706 break;
3707 }
3708 Err(e) => {
3709 if generation_flag.load(Ordering::Acquire) != generation {
3710 break;
3711 }
3712
3713 if auth_tracker.is_authenticated() {
3714 break;
3715 }
3716
3717 let delay = reconnect_auth_retry_delay(attempt);
3718
3719 log::warn!(
3720 "Re-authentication after reconnection failed, retrying in {delay:?}: {e}"
3721 );
3722
3723 tokio::select! {
3724 biased;
3725 () = abort.cancelled() => break,
3726 () = time::sleep(delay) => {}
3727 }
3728 }
3729 }
3730 }
3731}
3732
3733pub(crate) fn ws_channel_for_book(channel: OKXBookChannel) -> OKXWsChannel {
3734 match channel {
3735 OKXBookChannel::Book => OKXWsChannel::Books,
3736 OKXBookChannel::BookL2Tbt => OKXWsChannel::BooksTbt,
3737 OKXBookChannel::Books50L2Tbt => OKXWsChannel::Books50Tbt,
3738 OKXBookChannel::BooksRpi => OKXWsChannel::BooksRpi,
3739 OKXBookChannel::SprdBooks5 => OKXWsChannel::SprdBooks5,
3740 }
3741}
3742
3743fn log_receiver_dropped(signal: &AtomicBool, item: &str) {
3744 if signal.load(Ordering::Acquire) {
3745 log::debug!("Receiver dropped after stop signal while forwarding {item}");
3746 } else {
3747 log::error!("Failed to send {item} through channel: receiver dropped");
3748 }
3749}
3750
3751#[cfg(test)]
3752mod tests {
3753 use nautilus_core::time::get_atomic_clock_realtime;
3754 use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
3755 use nautilus_model::{identifiers::ClientId, instruments::stubs::crypto_perpetual_ethusdt};
3756 use nautilus_network::RECONNECTED;
3757 use rstest::rstest;
3758 use tokio_tungstenite::tungstenite::Message;
3759
3760 use super::*;
3761 use crate::{
3762 common::{
3763 consts::{OKX_POST_ONLY_CANCEL_SOURCE, OKX_VENUE},
3764 enums::{
3765 OKXExecType, OKXOrderCategory, OKXOrderStatus, OKXPriceType, OKXQuickMarginType,
3766 OKXSelfTradePreventionMode, OKXSide,
3767 },
3768 },
3769 websocket::{
3770 handler::is_post_only_auto_cancel,
3771 messages::{OKXOrderMsg, OKXWebSocketError, OKXWsFrame},
3772 },
3773 };
3774
3775 struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
3776
3777 impl Drop for DropSignal {
3778 fn drop(&mut self) {
3779 if let Some(sender) = self.0.take() {
3780 let _ = sender.send(());
3781 }
3782 }
3783 }
3784
3785 struct BlockingDrop(Arc<(parking_lot::Mutex<bool>, parking_lot::Condvar)>);
3786
3787 impl Drop for BlockingDrop {
3788 fn drop(&mut self) {
3789 let (lock, condvar) = &*self.0;
3790 let mut released = lock.lock();
3791 condvar.wait_while(&mut released, |released| !*released);
3792 }
3793 }
3794
3795 #[rstest]
3796 #[case(OKXBookChannel::Book, OKXWsChannel::Books)]
3797 #[case(OKXBookChannel::BookL2Tbt, OKXWsChannel::BooksTbt)]
3798 #[case(OKXBookChannel::Books50L2Tbt, OKXWsChannel::Books50Tbt)]
3799 #[case(OKXBookChannel::BooksRpi, OKXWsChannel::BooksRpi)]
3800 #[case(OKXBookChannel::SprdBooks5, OKXWsChannel::SprdBooks5)]
3801 fn test_ws_channel_for_book(#[case] channel: OKXBookChannel, #[case] expected: OKXWsChannel) {
3802 assert_eq!(ws_channel_for_book(channel), expected);
3803 }
3804
3805 #[rstest]
3806 fn test_timestamp_format_for_websocket_auth() {
3807 let now = UnixNanos::new(1_700_000_000_999_999_999);
3808
3809 assert_eq!(authentication_timestamp(now), "1700000000");
3810 }
3811
3812 #[rstest]
3813 fn test_subscription_args_are_sorted_by_topic() {
3814 let client = OKXWebSocketClient::default();
3815 client
3816 .subscriptions_inst_type
3817 .entry(OKXWsChannel::Instruments)
3818 .or_default()
3819 .extend([OKXInstrumentType::Swap, OKXInstrumentType::Spot]);
3820 client
3821 .subscriptions_inst_family
3822 .entry(OKXWsChannel::OpenInterest)
3823 .or_default()
3824 .insert(Ustr::from("BTC-USD"));
3825 client
3826 .subscriptions_inst_id
3827 .entry(OKXWsChannel::Tickers)
3828 .or_default()
3829 .extend([Ustr::from("ETH-USDT"), Ustr::from("BTC-USDT")]);
3830 client.subscriptions_bare.insert(OKXWsChannel::Status, true);
3831
3832 let topics = subscription_args(
3833 &client.subscriptions_inst_type,
3834 &client.subscriptions_inst_family,
3835 &client.subscriptions_inst_id,
3836 &client.subscriptions_bare,
3837 )
3838 .iter()
3839 .map(topic_from_subscription_arg)
3840 .collect::<Vec<_>>();
3841
3842 assert_eq!(
3843 topics,
3844 [
3845 "Instruments:Spot",
3846 "Instruments:Swap",
3847 "OpenInterest:BTC-USD",
3848 "Status",
3849 "Tickers:BTC-USDT",
3850 "Tickers:ETH-USDT",
3851 ]
3852 );
3853 }
3854
3855 #[rstest]
3856 fn test_new_without_credentials() {
3857 let client = OKXWebSocketClient::default();
3858 assert!(client.credential.is_none());
3859 assert_eq!(client.api_key(), None);
3860 }
3861
3862 #[rstest]
3863 fn test_instruments_cache_arc_observes_post_clone_writes() {
3864 let client = OKXWebSocketClient::default();
3865 let cache = client.instruments_cache_arc();
3866 assert!(cache.load().is_empty());
3867
3868 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3869 let symbol = instrument.symbol().inner();
3870 client.cache_instruments(std::slice::from_ref(&instrument));
3871
3872 let loaded = cache.load();
3873 assert_eq!(loaded.len(), 1);
3874 let stored = loaded.get(&symbol).expect("instrument not refreshed");
3875 assert_eq!(stored.id(), instrument.id());
3876 }
3877
3878 #[rstest]
3879 fn test_add_option_greeks_sub_defaults_to_both_conventions() {
3880 let client = OKXWebSocketClient::default();
3881 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3882
3883 client.add_option_greeks_sub(instrument_id);
3884
3885 let subs = client.option_greeks_subs().load();
3886 let stored = subs.get(&instrument_id).expect("instrument not registered");
3887 assert_eq!(stored.len(), 2);
3888 assert!(stored.contains(&OKXGreeksType::Bs));
3889 assert!(stored.contains(&OKXGreeksType::Pa));
3890 }
3891
3892 #[rstest]
3893 #[case::bs_only(vec![OKXGreeksType::Bs])]
3894 #[case::pa_only(vec![OKXGreeksType::Pa])]
3895 #[case::both(vec![OKXGreeksType::Bs, OKXGreeksType::Pa])]
3896 fn test_add_option_greeks_sub_with_conventions_stores_requested_set(
3897 #[case] conventions: Vec<OKXGreeksType>,
3898 ) {
3899 let client = OKXWebSocketClient::default();
3900 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3901 let set: AHashSet<OKXGreeksType> = conventions.iter().copied().collect();
3902
3903 client.add_option_greeks_sub_with_conventions(instrument_id, set.clone());
3904
3905 let subs = client.option_greeks_subs().load();
3906 let stored = subs.get(&instrument_id).expect("instrument not registered");
3907 assert_eq!(stored, &set);
3908 }
3909
3910 #[rstest]
3911 fn test_add_option_greeks_sub_with_empty_conventions_falls_back_to_both() {
3912 let client = OKXWebSocketClient::default();
3913 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3914
3915 client.add_option_greeks_sub_with_conventions(instrument_id, AHashSet::new());
3916
3917 let subs = client.option_greeks_subs().load();
3918 let stored = subs.get(&instrument_id).expect("instrument not registered");
3919 assert_eq!(stored.len(), 2);
3920 }
3921
3922 #[rstest]
3923 fn test_remove_option_greeks_sub_clears_entry() {
3924 let client = OKXWebSocketClient::default();
3925 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3926
3927 client.add_option_greeks_sub(instrument_id);
3928 client.remove_option_greeks_sub(&instrument_id);
3929
3930 let subs = client.option_greeks_subs().load();
3931 assert!(!subs.contains_key(&instrument_id));
3932 }
3933
3934 #[rstest]
3935 fn test_new_with_credentials() {
3936 let client = OKXWebSocketClient::new(
3937 None,
3938 Some("test_key".to_string()),
3939 Some("test_secret".to_string()),
3940 Some("test_passphrase".to_string()),
3941 None,
3942 None,
3943 None,
3944 TransportBackend::default(),
3945 None,
3946 )
3947 .unwrap();
3948 assert!(client.credential.is_some());
3949 assert_eq!(client.api_key(), Some("test_key"));
3950 }
3951
3952 #[rstest]
3953 fn test_new_partial_credentials_fails() {
3954 let result = OKXWebSocketClient::new(
3955 None,
3956 Some("test_key".to_string()),
3957 None,
3958 Some("test_passphrase".to_string()),
3959 None,
3960 None,
3961 None,
3962 TransportBackend::default(),
3963 None,
3964 );
3965 result.unwrap_err();
3966 }
3967
3968 #[rstest]
3969 fn test_request_id_generation() {
3970 let client = OKXWebSocketClient::default();
3971
3972 let initial_counter = client.request_id_counter.load(Ordering::SeqCst);
3973
3974 let id1 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3975 let id2 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3976
3977 assert_eq!(id1, initial_counter);
3978 assert_eq!(id2, initial_counter + 1);
3979 assert_eq!(
3980 client.request_id_counter.load(Ordering::SeqCst),
3981 initial_counter + 2
3982 );
3983 }
3984
3985 #[rstest]
3986 fn test_client_state_management() {
3987 let client = OKXWebSocketClient::default();
3988
3989 assert!(client.is_closed());
3990 assert!(!client.is_active());
3991
3992 let client_with_heartbeat = OKXWebSocketClient::new(
3993 None,
3994 None,
3995 None,
3996 None,
3997 None,
3998 Some(30),
3999 None,
4000 TransportBackend::default(),
4001 None,
4002 )
4003 .unwrap();
4004
4005 assert!(client_with_heartbeat.heartbeat.is_some());
4006 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
4007 }
4008
4009 #[rstest]
4010 #[tokio::test]
4011 async fn begin_shutdown_stops_handler_before_bounded_close() {
4012 let client_id = ClientId::from("OKX-TEST");
4013 let endpoint = Ustr::from("okx-test-stream");
4014 let registry = SocketReconnectRegistry::default();
4015 let control =
4016 SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, ®istry);
4017 let _sink = control.sink();
4018 control.register(|| SocketReconnectRequestOutcome::Accepted);
4019 let mut client = OKXWebSocketClient::default().with_socket_control(control);
4020 client
4021 .connection_mode
4022 .load()
4023 .store(ConnectionMode::Active.as_u8(), Ordering::SeqCst);
4024 let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
4025 let signal = DropSignal(Some(drop_tx));
4026 let handler_abort = CancellationToken::new();
4027 *client.handler_abort.lock() = handler_abort.clone();
4028 client
4029 .handler_tasks
4030 .spawn(async move {
4031 let _signal = signal;
4032 handler_abort.cancelled().await;
4033 })
4034 .expect("handler task should register");
4035
4036 assert!(registry.handle(client_id, endpoint).is_some());
4037 client.begin_shutdown();
4038
4039 tokio::time::timeout(Duration::from_secs(1), drop_rx)
4040 .await
4041 .expect("begin shutdown must drop the handler task")
4042 .expect("drop signal");
4043 assert!(client.is_closed());
4044 assert!(!client.handler_tasks.is_open());
4045 assert!(registry.handle(client_id, endpoint).is_some());
4046
4047 client.close().await.expect("bounded close");
4048 assert!(!client.has_task());
4049 assert!(registry.handle(client_id, endpoint).is_none());
4050 }
4051
4052 #[rstest]
4053 #[tokio::test]
4054 async fn connect_rollback_closes_handler_admission_and_deregisters_socket() {
4055 let client_id = ClientId::from("OKX-CONNECT-ROLLBACK");
4056 let endpoint = Ustr::from("okx-connect-rollback");
4057 let registry = SocketReconnectRegistry::default();
4058 let control =
4059 SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, ®istry);
4060 control.register(|| SocketReconnectRequestOutcome::Accepted);
4061 let handler_tasks = Arc::new(TaskGroup::new());
4062 let signal = Arc::new(AtomicBool::new(false));
4063 let handler_abort = CancellationToken::new();
4064
4065 let rollback = ConnectRollback {
4066 handler_tasks: Arc::clone(&handler_tasks),
4067 signal: Arc::clone(&signal),
4068 handler_abort: handler_abort.clone(),
4069 socket_control: Some(Arc::new(control)),
4070 armed: true,
4071 };
4072
4073 drop(rollback);
4074
4075 assert!(!handler_tasks.is_open());
4076 assert!(signal.load(Ordering::Acquire));
4077 assert!(handler_abort.is_cancelled());
4078 assert!(registry.handle(client_id, endpoint).is_none());
4079 handler_tasks
4080 .finish_shutdown(Duration::ZERO, Duration::from_secs(1))
4081 .await
4082 .expect("empty handler scope should drain");
4083 }
4084
4085 #[rstest]
4086 #[tokio::test]
4087 async fn request_close_signals_before_handler_shutdown() {
4088 let mut client = OKXWebSocketClient::default();
4089 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4090 client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
4091 client.signal.store(false, Ordering::Release);
4092
4093 client.request_close().await;
4094
4095 assert!(client.signal.load(Ordering::Acquire));
4096 assert!(matches!(cmd_rx.try_recv(), Ok(HandlerCommand::Disconnect)));
4097 }
4098
4099 #[rstest]
4100 #[tokio::test]
4101 async fn close_joins_handler_shared_with_clone() {
4102 let mut client = OKXWebSocketClient::default();
4103 let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
4104 let signal = DropSignal(Some(drop_tx));
4105 client
4106 .handler_tasks
4107 .spawn(async move {
4108 let _signal = signal;
4109 std::future::pending::<()>().await;
4110 })
4111 .expect("handler task should register");
4112 let retained = client.clone();
4113
4114 client.close().await.expect("close with retained clone");
4115
4116 tokio::time::timeout(Duration::from_secs(1), drop_rx)
4117 .await
4118 .expect("close must drop the handler task")
4119 .expect("drop signal");
4120 assert!(!client.has_task());
4121 assert!(!retained.has_task());
4122 }
4123
4124 #[rstest]
4125 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4126 async fn timeout_retains_unfinished_handler_task() {
4127 let mut client = OKXWebSocketClient::default();
4128 let release = Arc::new((parking_lot::Mutex::new(false), parking_lot::Condvar::new()));
4129 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4130 let blocking_drop = BlockingDrop(Arc::clone(&release));
4131 client
4132 .handler_tasks
4133 .spawn(async move {
4134 let _blocking_drop = blocking_drop;
4135 started_tx.send(()).expect("started receiver");
4136 std::future::pending::<()>().await;
4137 })
4138 .expect("handler task should register");
4139 started_rx.await.expect("blocking task started");
4140 client.begin_shutdown();
4141
4142 let result = client.close_stream_task(Duration::from_millis(10)).await;
4143 let retained = client.has_task();
4144 let reconnect_result = client.connect().await;
4145
4146 let (lock, condvar) = &*release;
4147 *lock.lock() = true;
4148 condvar.notify_all();
4149
4150 client
4151 .close_stream_task(Duration::from_secs(1))
4152 .await
4153 .expect("blocking handler task terminated");
4154
4155 assert!(result.is_err());
4156 assert!(retained);
4157 assert_eq!(
4158 reconnect_result
4159 .expect_err("reconnect with unfinished handler")
4160 .to_string(),
4161 "Cannot connect while previous WebSocket handler task is still running"
4162 );
4163 assert!(!client.has_task());
4164 }
4165
4166 #[rstest]
4167 fn test_websocket_error_handling() {
4168 let clock = get_atomic_clock_realtime();
4169 let ts = clock.get_time_ns().as_u64();
4170
4171 let error = OKXWebSocketError {
4172 code: "60012".to_string(),
4173 message: "Invalid request".to_string(),
4174 conn_id: None,
4175 timestamp: ts,
4176 };
4177
4178 assert_eq!(error.code, "60012");
4179 assert_eq!(error.message, "Invalid request");
4180 assert_eq!(error.timestamp, ts);
4181
4182 let nautilus_msg = OKXWsMessage::Error(error);
4183 match nautilus_msg {
4184 OKXWsMessage::Error(e) => {
4185 assert_eq!(e.code, "60012");
4186 assert_eq!(e.message, "Invalid request");
4187 }
4188 _ => panic!("Expected Error variant"),
4189 }
4190 }
4191
4192 #[rstest]
4193 fn test_request_id_generation_sequence() {
4194 let client = OKXWebSocketClient::default();
4195
4196 let initial_counter = client
4197 .request_id_counter
4198 .load(std::sync::atomic::Ordering::SeqCst);
4199 let mut ids = Vec::new();
4200
4201 for _ in 0..10 {
4202 let id = client
4203 .request_id_counter
4204 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4205 ids.push(id);
4206 }
4207
4208 for (i, &id) in ids.iter().enumerate() {
4209 assert_eq!(id, initial_counter + i as u64);
4210 }
4211
4212 assert_eq!(
4213 client
4214 .request_id_counter
4215 .load(std::sync::atomic::Ordering::SeqCst),
4216 initial_counter + 10
4217 );
4218 }
4219
4220 #[rstest]
4221 fn test_client_state_transitions() {
4222 let client = OKXWebSocketClient::default();
4223
4224 assert!(client.is_closed());
4225 assert!(!client.is_active());
4226
4227 let client_with_heartbeat = OKXWebSocketClient::new(
4228 None,
4229 None,
4230 None,
4231 None,
4232 None,
4233 Some(30), None,
4235 TransportBackend::default(),
4236 None,
4237 )
4238 .unwrap();
4239
4240 assert!(client_with_heartbeat.heartbeat.is_some());
4241 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
4242 }
4243
4244 #[rstest]
4245 fn test_websocket_error_scenarios() {
4246 let clock = get_atomic_clock_realtime();
4247 let ts = clock.get_time_ns().as_u64();
4248
4249 let error_scenarios = vec![
4250 ("60012", "Invalid request", None),
4251 ("60009", "Invalid API key", Some("conn-123".to_string())),
4252 ("60014", "Too many requests", None),
4253 ("50001", "Order not found", None),
4254 ];
4255
4256 for (code, message, conn_id) in error_scenarios {
4257 let error = OKXWebSocketError {
4258 code: code.to_string(),
4259 message: message.to_string(),
4260 conn_id: conn_id.clone(),
4261 timestamp: ts,
4262 };
4263
4264 assert_eq!(error.code, code);
4265 assert_eq!(error.message, message);
4266 assert_eq!(error.conn_id, conn_id);
4267 assert_eq!(error.timestamp, ts);
4268
4269 let nautilus_msg = OKXWsMessage::Error(error);
4270 match nautilus_msg {
4271 OKXWsMessage::Error(e) => {
4272 assert_eq!(e.code, code);
4273 assert_eq!(e.message, message);
4274 assert_eq!(e.conn_id, conn_id);
4275 }
4276 _ => panic!("Expected Error variant"),
4277 }
4278 }
4279 }
4280
4281 #[rstest]
4282 fn test_feed_handler_reconnection_detection() {
4283 let msg = Message::Text(RECONNECTED.to_string().into());
4284 let result = OKXWsFeedHandler::parse_raw_message(msg);
4285 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4286 }
4287
4288 #[rstest]
4289 fn test_feed_handler_normal_message_processing() {
4290 let ping_msg = Message::Text(TEXT_PING.to_string().into());
4291 let result = OKXWsFeedHandler::parse_raw_message(ping_msg);
4292 assert!(matches!(result, Some(OKXWsFrame::Ping)));
4293
4294 let sub_msg = r#"{
4295 "event": "subscribe",
4296 "arg": {
4297 "channel": "tickers",
4298 "instType": "SPOT"
4299 },
4300 "connId": "a4d3ae55"
4301 }"#;
4302
4303 let sub_result =
4304 OKXWsFeedHandler::parse_raw_message(Message::Text(sub_msg.to_string().into()));
4305 assert!(matches!(sub_result, Some(OKXWsFrame::Subscription { .. })));
4306 }
4307
4308 #[rstest]
4309 fn test_feed_handler_close_message() {
4310 let result = OKXWsFeedHandler::parse_raw_message(Message::Close(None));
4311 assert!(result.is_none());
4312 }
4313
4314 #[rstest]
4315 fn test_reconnection_message_constant() {
4316 assert_eq!(RECONNECTED, "__RECONNECTED__");
4317 }
4318
4319 #[rstest]
4320 fn reconnect_auth_retry_delay_grows_and_caps() {
4321 assert_eq!(reconnect_auth_retry_delay(1), Duration::from_secs(1));
4322 assert_eq!(reconnect_auth_retry_delay(2), Duration::from_secs(2));
4323 assert_eq!(reconnect_auth_retry_delay(3), Duration::from_secs(4));
4324 assert_eq!(reconnect_auth_retry_delay(6), Duration::from_secs(30));
4325 assert_eq!(
4326 reconnect_auth_retry_delay(u32::MAX),
4327 Duration::from_secs(30)
4328 );
4329 }
4330
4331 #[rstest]
4332 fn resubscribe_batches_subscription_args_into_venue_message_size() {
4333 let args: Vec<_> = (0..300)
4334 .map(|i| OKXSubscriptionArg {
4335 channel: OKXWsChannel::Tickers,
4336 inst_type: None,
4337 inst_family: None,
4338 inst_id: Some(Ustr::from(&format!("INST-{i}"))),
4339 })
4340 .collect();
4341
4342 let batches: Vec<_> = subscription_arg_batches(&args).collect();
4343
4344 assert_eq!(OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE, 256);
4345 assert_eq!(batches.len(), 2);
4346 assert_eq!(batches[0].len(), 256);
4347 assert_eq!(batches[1].len(), 44);
4348 }
4349
4350 #[rstest]
4351 #[tokio::test]
4352 async fn authenticate_session_sends_login_and_completes_on_success() {
4353 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4354 let cmd_tx = tokio::sync::RwLock::new(tx);
4355 let auth_tracker = AuthTracker::new();
4356 let credential = Credential::new("key".into(), "secret".into(), "pass".into());
4357 let clock = get_atomic_clock_realtime();
4358 let tracker = auth_tracker.clone();
4359
4360 let task = tokio::spawn(async move {
4361 authenticate_session(&credential, &auth_tracker, &cmd_tx, clock, 5).await
4362 });
4363
4364 match rx.recv().await.expect("authenticate command") {
4365 HandlerCommand::Authenticate { .. } => {}
4366 other => panic!("Expected HandlerCommand::Authenticate, was {other:?}"),
4367 }
4368
4369 tracker.succeed();
4370 task.await
4371 .expect("authenticate task")
4372 .expect("authentication should succeed");
4373 }
4374
4375 #[rstest]
4376 fn auth_attempt_superseded_matches_tracker_message() {
4377 let error = Error::Io(std::io::Error::other("Authentication attempt superseded"));
4378 assert!(auth_attempt_superseded(&error));
4379 let other = Error::Io(std::io::Error::other("Authentication timed out"));
4380 assert!(!auth_attempt_superseded(&other));
4381 }
4382
4383 #[rstest]
4384 #[tokio::test]
4385 async fn retry_reconnect_authentication_stops_when_already_authenticated() {
4386 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4387 let cmd_tx = Arc::new(tokio::sync::RwLock::new(tx));
4388 let auth_tracker = AuthTracker::new();
4389 auth_tracker.succeed();
4390
4391 tokio::time::timeout(
4392 Duration::from_millis(200),
4393 retry_reconnect_authentication(
4394 Credential::new("key".into(), "secret".into(), "pass".into()),
4395 auth_tracker,
4396 cmd_tx,
4397 get_atomic_clock_realtime(),
4398 5,
4399 Arc::new(AtomicBool::new(false)),
4400 CancellationToken::new(),
4401 Arc::new(AtomicU64::new(1)),
4402 1,
4403 ),
4404 )
4405 .await
4406 .expect("authenticated retry must return without sending login");
4407 assert!(
4408 rx.try_recv().is_err(),
4409 "already-authenticated retry must not send a login command"
4410 );
4411 }
4412
4413 #[rstest]
4414 #[tokio::test]
4415 async fn retry_reconnect_authentication_stops_when_aborted() {
4416 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4417 let cmd_tx = Arc::new(tokio::sync::RwLock::new(tx));
4418 let abort = CancellationToken::new();
4419 abort.cancel();
4420
4421 tokio::time::timeout(
4422 Duration::from_millis(200),
4423 retry_reconnect_authentication(
4424 Credential::new("key".into(), "secret".into(), "pass".into()),
4425 AuthTracker::new(),
4426 cmd_tx,
4427 get_atomic_clock_realtime(),
4428 5,
4429 Arc::new(AtomicBool::new(false)),
4430 abort,
4431 Arc::new(AtomicU64::new(1)),
4432 1,
4433 ),
4434 )
4435 .await
4436 .expect("aborted retry must return without waiting for authentication");
4437 }
4438
4439 #[rstest]
4440 fn test_multiple_reconnection_signals() {
4441 for _ in 0..3 {
4442 let msg = Message::Text(RECONNECTED.to_string().into());
4443 let result = OKXWsFeedHandler::parse_raw_message(msg);
4444 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4445 }
4446 }
4447
4448 #[tokio::test]
4449 async fn test_wait_until_active_timeout() {
4450 let client = OKXWebSocketClient::new(
4451 None,
4452 Some("test_key".to_string()),
4453 Some("test_secret".to_string()),
4454 Some("test_passphrase".to_string()),
4455 Some(AccountId::from("test-account")),
4456 None,
4457 None,
4458 TransportBackend::default(),
4459 None,
4460 )
4461 .unwrap();
4462
4463 let result = client.wait_until_active(0.1).await;
4464
4465 assert!(result.is_err());
4466 assert!(!client.is_active());
4467 }
4468
4469 fn sample_canceled_order_msg() -> OKXOrderMsg {
4470 OKXOrderMsg {
4471 acc_fill_sz: Some("0".to_string()),
4472 avg_px: "0".to_string(),
4473 c_time: 0,
4474 cancel_source: None,
4475 cancel_source_reason: None,
4476 category: OKXOrderCategory::Normal,
4477 ccy: Ustr::from("USDT"),
4478 cl_ord_id: "order-1".to_string(),
4479 algo_cl_ord_id: None,
4480 attach_algo_cl_ord_id: None,
4481 attach_algo_ords: Vec::new(),
4482 outcome: None,
4483 fee: None,
4484 fee_ccy: Ustr::from("USDT"),
4485 fill_px: "0".to_string(),
4486 fill_sz: "0".to_string(),
4487 fill_time: 0,
4488 inst_id: Ustr::from("ETH-USDT-SWAP"),
4489 inst_type: OKXInstrumentType::Swap,
4490 lever: "1".to_string(),
4491 ord_id: Ustr::from("123456"),
4492 ord_type: OKXOrderType::Limit,
4493 pnl: "0".to_string(),
4494 pos_side: OKXPositionSide::Net,
4495 px: "0".to_string(),
4496 reduce_only: "false".to_string(),
4497 side: OKXSide::Buy,
4498 state: OKXOrderStatus::Canceled,
4499 exec_type: OKXExecType::None,
4500 sz: "1".to_string(),
4501 td_mode: OKXTradeMode::Cross,
4502 tgt_ccy: None,
4503 trade_id: String::new(),
4504 algo_id: None,
4505 fill_fee: None,
4506 fill_fee_ccy: None,
4507 fill_mark_px: None,
4508 fill_mark_vol: None,
4509 fill_px_vol: None,
4510 fill_px_usd: None,
4511 fill_fwd_px: None,
4512 fill_notional_usd: None,
4513 fill_pnl: None,
4514 is_tp_limit: None,
4515 linked_algo_ord: None,
4516 notional_usd: None,
4517 px_type: OKXPriceType::None,
4518 px_usd: None,
4519 px_vol: None,
4520 quick_mgn_type: OKXQuickMarginType::None,
4521 rebate: None,
4522 rebate_ccy: None,
4523 sl_ord_px: None,
4524 sl_trigger_px: None,
4525 sl_trigger_px_type: None,
4526 source: None,
4527 stp_id: None,
4528 stp_mode: OKXSelfTradePreventionMode::None,
4529 tag: None,
4530 tp_ord_px: None,
4531 tp_trigger_px: None,
4532 tp_trigger_px_type: None,
4533 amend_result: None,
4534 req_id: None,
4535 code: None,
4536 msg: None,
4537 u_time: 0,
4538 }
4539 }
4540
4541 #[rstest]
4542 fn test_is_post_only_auto_cancel_detects_cancel_source() {
4543 let mut msg = sample_canceled_order_msg();
4544 msg.cancel_source = Some(OKX_POST_ONLY_CANCEL_SOURCE.to_string());
4545
4546 assert!(is_post_only_auto_cancel(&msg));
4547 }
4548
4549 #[rstest]
4550 fn test_is_post_only_auto_cancel_detects_reason() {
4551 let mut msg = sample_canceled_order_msg();
4552 msg.cancel_source_reason = Some("POST_ONLY would take liquidity".to_string());
4553
4554 assert!(is_post_only_auto_cancel(&msg));
4555 }
4556
4557 #[rstest]
4558 fn test_is_post_only_auto_cancel_false_without_markers() {
4559 let msg = sample_canceled_order_msg();
4560
4561 assert!(!is_post_only_auto_cancel(&msg));
4562 }
4563
4564 #[rstest]
4565 fn test_is_post_only_auto_cancel_false_for_order_type_only() {
4566 let mut msg = sample_canceled_order_msg();
4567 msg.ord_type = OKXOrderType::PostOnly;
4568
4569 assert!(!is_post_only_auto_cancel(&msg));
4570 }
4571
4572 #[tokio::test]
4573 async fn test_batch_cancel_orders_with_multiple_orders() {
4574 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4575
4576 let client = OKXWebSocketClient::new(
4577 Some("wss://test.okx.com".to_string()),
4578 None,
4579 None,
4580 None,
4581 None,
4582 None,
4583 None,
4584 TransportBackend::default(),
4585 None,
4586 )
4587 .expect("Failed to create client");
4588
4589 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4590 let client_order_id1 = ClientOrderId::new("order1");
4591 let client_order_id2 = ClientOrderId::new("order2");
4592 let venue_order_id1 = VenueOrderId::new("venue1");
4593 let venue_order_id2 = VenueOrderId::new("venue2");
4594
4595 let orders = vec![
4596 (instrument_id, Some(client_order_id1), Some(venue_order_id1)),
4597 (instrument_id, Some(client_order_id2), Some(venue_order_id2)),
4598 ];
4599
4600 let result = client.batch_cancel_orders(orders).await;
4601 assert!(result.is_err());
4602 }
4603
4604 #[tokio::test]
4605 async fn test_batch_cancel_orders_with_only_client_order_id() {
4606 use nautilus_model::identifiers::{ClientOrderId, InstrumentId};
4607
4608 let client = OKXWebSocketClient::new(
4609 Some("wss://test.okx.com".to_string()),
4610 None,
4611 None,
4612 None,
4613 None,
4614 None,
4615 None,
4616 TransportBackend::default(),
4617 None,
4618 )
4619 .expect("Failed to create client");
4620
4621 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4622 let client_order_id = ClientOrderId::new("order1");
4623
4624 let orders = vec![(instrument_id, Some(client_order_id), None)];
4625
4626 let result = client.batch_cancel_orders(orders).await;
4627
4628 assert!(result.is_err());
4629 }
4630
4631 #[tokio::test]
4632 async fn test_batch_cancel_orders_with_only_venue_order_id() {
4633 use nautilus_model::identifiers::{InstrumentId, VenueOrderId};
4634
4635 let client = OKXWebSocketClient::new(
4636 Some("wss://test.okx.com".to_string()),
4637 None,
4638 None,
4639 None,
4640 None,
4641 None,
4642 None,
4643 TransportBackend::default(),
4644 None,
4645 )
4646 .expect("Failed to create client");
4647
4648 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4649 let venue_order_id = VenueOrderId::new("venue1");
4650
4651 let orders = vec![(instrument_id, None, Some(venue_order_id))];
4652
4653 let result = client.batch_cancel_orders(orders).await;
4654
4655 assert!(result.is_err());
4656 }
4657
4658 #[tokio::test]
4659 async fn test_batch_cancel_orders_with_both_ids() {
4660 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4661
4662 let client = OKXWebSocketClient::new(
4663 Some("wss://test.okx.com".to_string()),
4664 None,
4665 None,
4666 None,
4667 None,
4668 None,
4669 None,
4670 TransportBackend::default(),
4671 None,
4672 )
4673 .expect("Failed to create client");
4674
4675 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4676 let client_order_id = ClientOrderId::new("order1");
4677 let venue_order_id = VenueOrderId::new("venue1");
4678
4679 let orders = vec![(instrument_id, Some(client_order_id), Some(venue_order_id))];
4680
4681 let result = client.batch_cancel_orders(orders).await;
4682
4683 assert!(result.is_err());
4684 }
4685
4686 #[tokio::test]
4687 async fn test_cancel_order_fails_without_inst_id_code() {
4688 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4689
4690 let client = OKXWebSocketClient::default();
4691 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4692
4693 let result = client
4694 .cancel_order(
4695 TraderId::from("TESTER-001"),
4696 StrategyId::from("S-001"),
4697 instrument_id,
4698 Some(ClientOrderId::new("O-001")),
4699 None,
4700 )
4701 .await;
4702
4703 assert!(result.is_err());
4704 let err = result.unwrap_err().to_string();
4705 assert!(
4706 err.contains("No instIdCode cached for BTC-USDT-SWAP.OKX"),
4707 "Expected instIdCode error, found: {err}"
4708 );
4709 }
4710
4711 #[tokio::test]
4712 async fn test_submit_order_fails_without_inst_id_code() {
4713 use nautilus_model::{
4714 enums::{OrderSide, OrderType},
4715 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
4716 types::Quantity,
4717 };
4718
4719 use crate::common::enums::OKXTradeMode;
4720
4721 let client = OKXWebSocketClient::default();
4722 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
4723
4724 let result = client
4725 .submit_order(
4726 TraderId::from("TESTER-001"),
4727 StrategyId::from("S-001"),
4728 instrument_id,
4729 OKXTradeMode::Cross,
4730 ClientOrderId::new("O-001"),
4731 OrderSide::Buy,
4732 OrderType::Limit,
4733 Quantity::from("0.01"),
4734 None,
4735 None,
4736 None,
4737 None,
4738 None,
4739 None,
4740 None,
4741 None,
4742 None,
4743 None,
4744 None,
4745 None,
4746 None,
4747 None,
4748 None,
4749 )
4750 .await;
4751
4752 assert!(result.is_err());
4753 let err = result.unwrap_err().to_string();
4754 assert!(
4755 err.contains("No instIdCode cached for ETH-USDT-SWAP.OKX"),
4756 "Expected instIdCode error, found: {err}"
4757 );
4758 }
4759
4760 #[tokio::test]
4761 async fn test_cancel_order_passes_inst_id_code_lookup_when_cached() {
4762 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4763 use ustr::Ustr;
4764
4765 let client = OKXWebSocketClient::default();
4766 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4767
4768 client.cache_inst_id_code(Ustr::from("BTC-USDT-SWAP"), 10459);
4770
4771 let result = client
4772 .cancel_order(
4773 TraderId::from("TESTER-001"),
4774 StrategyId::from("S-001"),
4775 instrument_id,
4776 Some(ClientOrderId::new("O-001")),
4777 None,
4778 )
4779 .await;
4780
4781 assert!(result.is_err());
4783 let err = result.unwrap_err().to_string();
4784 assert!(
4785 !err.contains("No instIdCode cached"),
4786 "Should pass instIdCode lookup, found: {err}"
4787 );
4788 }
4789
4790 #[rstest]
4791 fn test_inst_id_code_cache_keeps_usd_and_usdc_instruments_distinct() {
4792 use ustr::Ustr;
4793
4794 let client = OKXWebSocketClient::default();
4795 client.cache_inst_id_code(Ustr::from("BTC-USD"), 10401);
4796 client.cache_inst_id_code(Ustr::from("BTC-USDC"), 20459);
4797
4798 assert_eq!(client.get_inst_id_code(&Ustr::from("BTC-USD")), Some(10401));
4799 assert_eq!(
4800 client.get_inst_id_code(&Ustr::from("BTC-USDC")),
4801 Some(20459)
4802 );
4803 assert_ne!(
4804 client.get_inst_id_code(&Ustr::from("BTC-USD")),
4805 client.get_inst_id_code(&Ustr::from("BTC-USDC"))
4806 );
4807 }
4808
4809 #[rstest]
4810 fn test_race_unsubscribe_failure_recovery() {
4811 let client = OKXWebSocketClient::new(
4817 Some("wss://test.okx.com".to_string()),
4818 None,
4819 None,
4820 None,
4821 None,
4822 None,
4823 None,
4824 TransportBackend::default(),
4825 None,
4826 )
4827 .expect("Failed to create client");
4828
4829 let topic = "trades:BTC-USDT-SWAP";
4830
4831 client.subscriptions_state.mark_subscribe(topic);
4833 client.subscriptions_state.confirm_subscribe(topic);
4834 assert_eq!(client.subscriptions_state.len(), 1);
4835
4836 client.subscriptions_state.mark_unsubscribe(topic);
4838 assert_eq!(client.subscriptions_state.len(), 0);
4839 assert_eq!(
4840 client.subscriptions_state.pending_unsubscribe_topics(),
4841 vec![topic]
4842 );
4843
4844 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);
4852 assert!(
4853 client
4854 .subscriptions_state
4855 .pending_unsubscribe_topics()
4856 .is_empty()
4857 );
4858 assert!(
4859 client
4860 .subscriptions_state
4861 .pending_subscribe_topics()
4862 .is_empty()
4863 );
4864
4865 let all = client.subscriptions_state.all_topics();
4867 assert_eq!(all.len(), 1);
4868 assert!(all.contains(&topic.to_string()));
4869 }
4870
4871 #[rstest]
4872 fn test_race_resubscribe_before_unsubscribe_ack() {
4873 let client = OKXWebSocketClient::new(
4877 Some("wss://test.okx.com".to_string()),
4878 None,
4879 None,
4880 None,
4881 None,
4882 None,
4883 None,
4884 TransportBackend::default(),
4885 None,
4886 )
4887 .expect("Failed to create client");
4888
4889 let topic = "books:BTC-USDT";
4890
4891 client.subscriptions_state.mark_subscribe(topic);
4893 client.subscriptions_state.confirm_subscribe(topic);
4894 assert_eq!(client.subscriptions_state.len(), 1);
4895
4896 client.subscriptions_state.mark_unsubscribe(topic);
4898 assert_eq!(client.subscriptions_state.len(), 0);
4899 assert_eq!(
4900 client.subscriptions_state.pending_unsubscribe_topics(),
4901 vec![topic]
4902 );
4903
4904 client.subscriptions_state.mark_subscribe(topic);
4906 assert_eq!(
4907 client.subscriptions_state.pending_subscribe_topics(),
4908 vec![topic]
4909 );
4910
4911 client.subscriptions_state.confirm_unsubscribe(topic);
4913 assert!(
4914 client
4915 .subscriptions_state
4916 .pending_unsubscribe_topics()
4917 .is_empty()
4918 );
4919 assert_eq!(
4920 client.subscriptions_state.pending_subscribe_topics(),
4921 vec![topic]
4922 );
4923
4924 client.subscriptions_state.confirm_subscribe(topic);
4926 assert_eq!(client.subscriptions_state.len(), 1);
4927 assert!(
4928 client
4929 .subscriptions_state
4930 .pending_subscribe_topics()
4931 .is_empty()
4932 );
4933
4934 let all = client.subscriptions_state.all_topics();
4936 assert_eq!(all.len(), 1);
4937 assert!(all.contains(&topic.to_string()));
4938 }
4939
4940 #[rstest]
4941 fn test_race_late_subscribe_confirmation_after_unsubscribe() {
4942 let client = OKXWebSocketClient::new(
4945 Some("wss://test.okx.com".to_string()),
4946 None,
4947 None,
4948 None,
4949 None,
4950 None,
4951 None,
4952 TransportBackend::default(),
4953 None,
4954 )
4955 .expect("Failed to create client");
4956
4957 let topic = "tickers:ETH-USDT";
4958
4959 client.subscriptions_state.mark_subscribe(topic);
4961 assert_eq!(
4962 client.subscriptions_state.pending_subscribe_topics(),
4963 vec![topic]
4964 );
4965
4966 client.subscriptions_state.mark_unsubscribe(topic);
4968 assert!(
4969 client
4970 .subscriptions_state
4971 .pending_subscribe_topics()
4972 .is_empty()
4973 ); assert_eq!(
4975 client.subscriptions_state.pending_unsubscribe_topics(),
4976 vec![topic]
4977 );
4978
4979 client.subscriptions_state.confirm_subscribe(topic);
4981 assert_eq!(client.subscriptions_state.len(), 0); assert_eq!(
4983 client.subscriptions_state.pending_unsubscribe_topics(),
4984 vec![topic]
4985 );
4986
4987 client.subscriptions_state.confirm_unsubscribe(topic);
4989
4990 assert!(client.subscriptions_state.is_empty());
4992 assert!(client.subscriptions_state.all_topics().is_empty());
4993 }
4994
4995 #[rstest]
4996 fn test_race_reconnection_with_pending_states() {
4997 let client = OKXWebSocketClient::new(
4999 Some("wss://test.okx.com".to_string()),
5000 Some("test_key".to_string()),
5001 Some("test_secret".to_string()),
5002 Some("test_passphrase".to_string()),
5003 Some(AccountId::new("OKX-TEST")),
5004 None,
5005 None,
5006 TransportBackend::default(),
5007 None,
5008 )
5009 .expect("Failed to create client");
5010
5011 let trade_btc = "trades:BTC-USDT-SWAP";
5014 client.subscriptions_state.mark_subscribe(trade_btc);
5015 client.subscriptions_state.confirm_subscribe(trade_btc);
5016
5017 let trade_eth = "trades:ETH-USDT-SWAP";
5019 client.subscriptions_state.mark_subscribe(trade_eth);
5020
5021 let book_btc = "books:BTC-USDT";
5023 client.subscriptions_state.mark_subscribe(book_btc);
5024 client.subscriptions_state.confirm_subscribe(book_btc);
5025 client.subscriptions_state.mark_unsubscribe(book_btc);
5026
5027 let topics_to_restore = client.subscriptions_state.all_topics();
5029
5030 assert_eq!(topics_to_restore.len(), 2);
5032 assert!(topics_to_restore.contains(&trade_btc.to_string()));
5033 assert!(topics_to_restore.contains(&trade_eth.to_string()));
5034 assert!(!topics_to_restore.contains(&book_btc.to_string())); }
5036
5037 #[rstest]
5038 fn test_race_duplicate_subscribe_messages_idempotent() {
5039 let client = OKXWebSocketClient::new(
5042 Some("wss://test.okx.com".to_string()),
5043 None,
5044 None,
5045 None,
5046 None,
5047 None,
5048 None,
5049 TransportBackend::default(),
5050 None,
5051 )
5052 .expect("Failed to create client");
5053
5054 let topic = "trades:BTC-USDT-SWAP";
5055
5056 client.subscriptions_state.mark_subscribe(topic);
5058 client.subscriptions_state.confirm_subscribe(topic);
5059 assert_eq!(client.subscriptions_state.len(), 1);
5060
5061 client.subscriptions_state.mark_subscribe(topic);
5063 assert!(
5064 client
5065 .subscriptions_state
5066 .pending_subscribe_topics()
5067 .is_empty()
5068 ); assert_eq!(client.subscriptions_state.len(), 1); client.subscriptions_state.confirm_subscribe(topic);
5073 assert_eq!(client.subscriptions_state.len(), 1);
5074
5075 let all = client.subscriptions_state.all_topics();
5077 assert_eq!(all.len(), 1);
5078 assert_eq!(all[0], topic);
5079 }
5080}