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, 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, OKXVipLevel,
93 },
94 parse::{
95 bar_spec_as_okx_channel, okx_instrument_type, okx_instrument_type_from_symbol,
96 parse_base_quote_from_symbol,
97 },
98};
99
100pub static OKX_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
104 Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant")
105});
106
107pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
112 LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).expect("non-zero")));
113
114pub static OKX_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
116 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
117});
118
119pub static OKX_WS_BATCH_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
121 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant")
122});
123
124pub static OKX_WS_MASS_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
126 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
127});
128
129pub static OKX_WS_ALGO_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
131 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
132});
133
134pub static OKX_WS_ALGO_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
136 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
137});
138
139const OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE: usize = 256;
141
142const RECONNECT_AUTH_RETRY_INITIAL: Duration = Duration::from_secs(1);
143const RECONNECT_AUTH_RETRY_MAX: Duration = Duration::from_secs(30);
144
145pub static OKX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
150 LazyLock::new(|| [Ustr::from("subscription")]);
151
152pub static OKX_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("order")]);
156
157pub static OKX_RATE_LIMIT_KEY_BATCH_ORDER: LazyLock<[Ustr; 1]> =
161 LazyLock::new(|| [Ustr::from("batch-order")]);
162
163pub static OKX_RATE_LIMIT_KEY_CANCEL: LazyLock<[Ustr; 1]> =
167 LazyLock::new(|| [Ustr::from("cancel")]);
168
169pub static OKX_RATE_LIMIT_KEY_BATCH_CANCEL: LazyLock<[Ustr; 1]> =
173 LazyLock::new(|| [Ustr::from("batch-cancel")]);
174
175pub static OKX_RATE_LIMIT_KEY_MASS_CANCEL: LazyLock<[Ustr; 1]> =
179 LazyLock::new(|| [Ustr::from("mass-cancel")]);
180
181pub static OKX_RATE_LIMIT_KEY_AMEND: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("amend")]);
185
186pub static OKX_RATE_LIMIT_KEY_BATCH_AMEND: LazyLock<[Ustr; 1]> =
190 LazyLock::new(|| [Ustr::from("batch-amend")]);
191
192pub static OKX_RATE_LIMIT_KEY_ALGO_ORDER: LazyLock<[Ustr; 1]> =
196 LazyLock::new(|| [Ustr::from("algo-order")]);
197
198pub static OKX_RATE_LIMIT_KEY_ALGO_CANCEL: LazyLock<[Ustr; 1]> =
202 LazyLock::new(|| [Ustr::from("algo-cancel")]);
203
204#[derive(Debug, Clone)]
208#[allow(dead_code)]
209#[allow(
210 clippy::struct_field_names,
211 reason = "fields follow the codebase-wide trader_id/strategy_id/instrument_id naming family"
212)]
213pub(crate) struct PendingOrderInfo {
214 pub trader_id: TraderId,
215 pub strategy_id: StrategyId,
216 pub instrument_id: InstrumentId,
217}
218
219#[derive(Clone)]
221pub struct OKXWebSocketClient {
222 clock: &'static AtomicTime,
223 url: String,
224 vip_level: Arc<AtomicU8>,
225 credential: Option<Credential>,
226 heartbeat: Option<u64>,
227 auth_timeout_secs: u64,
228 auth_tracker: AuthTracker,
229 signal: Arc<AtomicBool>,
230 connection_mode: Arc<ArcSwap<AtomicU8>>,
231 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
232 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<OKXWsMessage>>>,
233 handler_tasks: Arc<TaskGroup>,
234 connect_lock: Arc<tokio::sync::Mutex<()>>,
235 handler_abort: Arc<Mutex<CancellationToken>>,
236 subscriptions_inst_type: Arc<DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>>,
237 subscriptions_inst_family: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
238 subscriptions_inst_id: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
239 subscriptions_bare: Arc<DashMap<OKXWsChannel, bool>>,
240 subscriptions_state: SubscriptionState,
241 request_id_counter: Arc<AtomicU64>,
242 instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
243 inst_id_code_cache: Arc<AtomicMap<Ustr, u64>>,
244 trade_quote_ccy_lists: Arc<AtomicMap<Ustr, Vec<Ustr>>>,
245 spot_trade_quote_ccy: Arc<Mutex<Option<Ustr>>>,
246 pub(crate) pending_orders: Arc<DashMap<String, PendingOrderInfo>>,
247 pub(crate) pending_cancels: Arc<DashMap<String, PendingOrderInfo>>,
248 pub(crate) pending_amends: Arc<DashMap<String, PendingOrderInfo>>,
249 option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
250 index_pair_subscribers: Arc<DashMap<Ustr, usize>>,
257 index_pair_transition: Arc<tokio::sync::Mutex<()>>,
262 transport_backend: TransportBackend,
264 proxy_url: Option<SecretString>,
266 cancellation_token: CancellationToken,
267 socket_control: Option<Arc<SocketControl>>,
268}
269
270struct ConnectRollback {
271 handler_tasks: Arc<TaskGroup>,
272 signal: Arc<AtomicBool>,
273 handler_abort: CancellationToken,
274 socket_control: Option<Arc<SocketControl>>,
275 armed: bool,
276}
277
278impl ConnectRollback {
279 fn disarm(&mut self) {
280 self.armed = false;
281 }
282}
283
284impl Drop for ConnectRollback {
285 fn drop(&mut self) {
286 if !self.armed {
287 return;
288 }
289
290 self.handler_tasks.begin_shutdown();
291 self.signal.store(true, Ordering::Release);
292 self.handler_abort.cancel();
293
294 if let Some(control) = &self.socket_control {
295 control.deregister();
296 }
297 }
298}
299
300impl Default for OKXWebSocketClient {
301 fn default() -> Self {
302 Self::new(
303 None,
304 None,
305 None,
306 None,
307 None,
308 None,
309 None,
310 TransportBackend::default(),
311 None,
312 )
313 .unwrap()
314 }
315}
316
317impl Debug for OKXWebSocketClient {
318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 f.debug_struct(stringify!(OKXWebSocketClient))
320 .field("url", &self.url)
321 .field("credential", &self.credential.as_ref().map(|_| REDACTED))
322 .field("heartbeat", &self.heartbeat)
323 .finish_non_exhaustive()
324 }
325}
326
327impl OKXWebSocketClient {
328 #[allow(clippy::too_many_arguments)]
334 pub fn new(
335 url: Option<String>,
336 api_key: Option<String>,
337 api_secret: Option<String>,
338 api_passphrase: Option<String>,
339 _account_id: Option<AccountId>,
340 heartbeat: Option<u64>,
341 auth_timeout_secs: Option<u64>,
342 transport_backend: TransportBackend,
343 proxy_url: Option<String>,
344 ) -> anyhow::Result<Self> {
345 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
346 let credential = match (api_key, api_secret, api_passphrase) {
347 (Some(key), Some(secret), Some(passphrase)) => {
348 Some(Credential::new(key, secret, passphrase))
349 }
350 (None, None, None) => None,
351 _ => anyhow::bail!(
352 "`api_key`, `api_secret`, `api_passphrase` credentials must be provided together"
353 ),
354 };
355
356 let signal = Arc::new(AtomicBool::new(false));
357 let subscriptions_inst_type = Arc::new(DashMap::new());
358 let subscriptions_inst_family = Arc::new(DashMap::new());
359 let subscriptions_inst_id = Arc::new(DashMap::new());
360 let subscriptions_bare = Arc::new(DashMap::new());
361 let subscriptions_state = SubscriptionState::new(OKX_WS_TOPIC_DELIMITER);
362
363 Ok(Self {
364 clock: get_atomic_clock_realtime(),
365 url,
366 vip_level: Arc::new(AtomicU8::new(0)),
367 credential,
368 heartbeat,
369 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
370 auth_tracker: AuthTracker::new(),
371 signal,
372 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
373 ConnectionMode::Closed.as_u8(),
374 ))),
375 cmd_tx: {
376 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
378 Arc::new(tokio::sync::RwLock::new(tx))
379 },
380 out_rx: None,
381 handler_tasks: Arc::new(TaskGroup::new()),
382 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
383 handler_abort: Arc::new(Mutex::new(CancellationToken::new())),
384 subscriptions_inst_type,
385 subscriptions_inst_family,
386 subscriptions_inst_id,
387 subscriptions_bare,
388 subscriptions_state,
389 request_id_counter: Arc::new(AtomicU64::new(1)),
390 instruments_cache: Arc::new(AtomicMap::new()),
391 inst_id_code_cache: Arc::new(AtomicMap::new()),
392 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
393 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
394 pending_orders: Arc::new(DashMap::new()),
395 pending_cancels: Arc::new(DashMap::new()),
396 pending_amends: Arc::new(DashMap::new()),
397 option_greeks_subs: Arc::new(AtomicMap::new()),
398 index_pair_subscribers: Arc::new(DashMap::new()),
399 index_pair_transition: Arc::new(tokio::sync::Mutex::new(())),
400 transport_backend,
401 proxy_url: proxy_url.map(SecretString::from),
402 cancellation_token: CancellationToken::new(),
403 socket_control: None,
404 })
405 }
406
407 #[must_use]
409 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
410 self.socket_control = Some(Arc::new(control));
411 self
412 }
413
414 #[allow(clippy::too_many_arguments)]
421 pub fn with_credentials(
422 url: Option<String>,
423 api_key: Option<String>,
424 api_secret: Option<String>,
425 api_passphrase: Option<String>,
426 account_id: Option<AccountId>,
427 heartbeat: Option<u64>,
428 auth_timeout_secs: Option<u64>,
429 transport_backend: TransportBackend,
430 proxy_url: Option<String>,
431 ) -> anyhow::Result<Self> {
432 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
433 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
434 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
435 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
436
437 Self::new(
438 Some(url),
439 Some(api_key),
440 Some(api_secret),
441 Some(api_passphrase),
442 account_id,
443 heartbeat,
444 auth_timeout_secs,
445 transport_backend,
446 proxy_url,
447 )
448 }
449
450 pub fn from_env() -> anyhow::Result<Self> {
457 let url = get_env_var("OKX_WS_URL")?;
458 let api_key = get_env_var("OKX_API_KEY")?;
459 let api_secret = get_env_var("OKX_API_SECRET")?;
460 let api_passphrase = get_env_var("OKX_API_PASSPHRASE")?;
461
462 Self::new(
463 Some(url),
464 Some(api_key),
465 Some(api_secret),
466 Some(api_passphrase),
467 None,
468 None,
469 None,
470 TransportBackend::default(),
471 None,
472 )
473 }
474
475 pub fn cancel_all_requests(&self) {
477 self.cancellation_token.cancel();
478 }
479
480 pub fn cancellation_token(&self) -> &CancellationToken {
482 &self.cancellation_token
483 }
484
485 pub fn url(&self) -> &str {
487 self.url.as_str()
488 }
489
490 pub fn api_key(&self) -> Option<&str> {
492 self.credential.as_ref().map(Credential::api_key)
493 }
494
495 #[must_use]
497 pub fn api_key_masked(&self) -> Option<String> {
498 self.credential.as_ref().map(Credential::api_key_masked)
499 }
500
501 pub fn is_active(&self) -> bool {
503 let connection_mode_arc = self.connection_mode.load();
504 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
505 && !self.signal.load(Ordering::Acquire)
506 }
507
508 pub fn is_closed(&self) -> bool {
510 let connection_mode_arc = self.connection_mode.load();
511 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
512 || self.signal.load(Ordering::Acquire)
513 }
514
515 pub(crate) fn has_task(&self) -> bool {
517 !self.handler_tasks.is_empty()
518 }
519
520 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
524 self.instruments_cache.rcu(|m| {
525 for inst in instruments {
526 m.insert(inst.symbol().inner(), inst.clone());
527 }
528 });
529 }
530
531 pub fn cache_instrument(&self, instrument: InstrumentAny) {
535 self.instruments_cache
536 .insert(instrument.symbol().inner(), instrument);
537 }
538
539 pub fn instruments_snapshot(&self) -> AHashMap<Ustr, InstrumentAny> {
541 (**self.instruments_cache.load()).clone()
542 }
543
544 pub fn instruments_cache_arc(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
546 Arc::clone(&self.instruments_cache)
547 }
548
549 pub fn cache_inst_id_code(&self, inst_id: Ustr, inst_id_code: u64) {
553 self.inst_id_code_cache.insert(inst_id, inst_id_code);
554 }
555
556 pub fn cache_inst_id_codes(&self, mappings: impl IntoIterator<Item = (Ustr, u64)>) {
560 let entries: Vec<_> = mappings.into_iter().collect();
561 self.inst_id_code_cache.rcu(|m| {
562 for (inst_id, inst_id_code) in &entries {
563 m.insert(*inst_id, *inst_id_code);
564 }
565 });
566 }
567
568 #[must_use]
572 pub fn get_inst_id_code(&self, inst_id: &Ustr) -> Option<u64> {
573 self.inst_id_code_cache.load().get(inst_id).copied()
574 }
575
576 pub fn set_spot_trade_quote_ccy(&self, ccy: Option<String>) {
578 *self.spot_trade_quote_ccy.lock() = ccy.map(|value| Ustr::from(value.as_str()));
579 }
580
581 pub fn cache_trade_quote_ccy_lists(
583 &self,
584 mappings: impl IntoIterator<Item = (Ustr, Vec<Ustr>)>,
585 ) {
586 let entries: Vec<_> = mappings.into_iter().collect();
587 self.trade_quote_ccy_lists.rcu(|m| {
588 for (inst_id, list) in &entries {
589 m.insert(*inst_id, list.clone());
590 }
591 });
592 }
593
594 fn resolve_spot_trade_quote_ccy(
595 &self,
596 instrument_type: OKXInstrumentType,
597 inst_id: Ustr,
598 ) -> Result<Option<Ustr>, OKXWsError> {
599 let configured = *self.spot_trade_quote_ccy.lock();
600 let available = self
601 .trade_quote_ccy_lists
602 .load()
603 .get(&inst_id)
604 .cloned()
605 .unwrap_or_default();
606 spot_trade_quote_ccy_wire_value(
607 instrument_type,
608 configured.as_ref().map(Ustr::as_str),
609 &available,
610 )
611 .map_err(OKXWsError::ClientError)
612 }
613
614 fn inst_id_symbol_and_code_from_snapshot(
615 inst_id_codes: &AHashMap<Ustr, u64>,
616 inst_id: &InstrumentId,
617 action: &str,
618 ) -> Result<(Ustr, u64), OKXWsError> {
619 let inst_id_symbol = inst_id.symbol.inner();
620 let inst_id_code = inst_id_codes.get(&inst_id_symbol).copied().ok_or_else(|| {
621 OKXWsError::ClientError(format!(
622 "No instIdCode cached for {inst_id}, cannot {action} order"
623 ))
624 })?;
625 Ok((inst_id_symbol, inst_id_code))
626 }
627
628 pub fn set_vip_level(&self, vip_level: OKXVipLevel) {
632 self.vip_level.store(vip_level as u8, Ordering::Relaxed);
633 }
634
635 pub fn vip_level(&self) -> OKXVipLevel {
637 let level = self.vip_level.load(Ordering::Relaxed);
638 OKXVipLevel::from(level)
639 }
640
641 pub async fn connect(&mut self) -> anyhow::Result<()> {
647 let connect_lock = Arc::clone(&self.connect_lock);
648 let _connect_guard = connect_lock.lock().await;
649
650 if !self.handler_tasks.is_empty() && !self.handler_tasks.all_finished() {
651 anyhow::bail!("Cannot connect while previous WebSocket handler task is still running");
652 }
653
654 if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
655 self.handler_tasks.begin_shutdown();
656 self.handler_tasks
657 .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
658 .await
659 .map_err(|e| anyhow::anyhow!("Previous WebSocket handler failed: {e}"))?;
660 self.handler_tasks.start_generation().map_err(|e| {
661 anyhow::anyhow!("Failed to start WebSocket handler task generation: {e}")
662 })?;
663 }
664 let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
665 anyhow::anyhow!("Failed to acquire WebSocket handler task spawner: {e}")
666 })?;
667 let handler_abort = CancellationToken::new();
668 *self.handler_abort.lock() = handler_abort.clone();
669 let mut rollback = ConnectRollback {
670 handler_tasks: Arc::clone(&self.handler_tasks),
671 signal: Arc::clone(&self.signal),
672 handler_abort: handler_abort.clone(),
673 socket_control: self.socket_control.clone(),
674 armed: true,
675 };
676
677 self.signal.store(false, Ordering::Release);
679
680 let (message_handler, raw_rx) = channel_message_handler();
681
682 let headers = create_standard_nautilus_headers();
688
689 let config = WebSocketConfig {
690 url: self.url.clone(),
691 headers,
692 heartbeat_interval_secs: self.heartbeat,
693 heartbeat_payload: Some(TEXT_PING.to_string()),
694 connect_timeout_ms: None,
695 reconnect_delay_initial_ms: None,
696 reconnect_delay_max_ms: None,
697 reconnect_backoff_factor: None,
698 reconnect_jitter_ms: None,
699 reconnect_max_attempts: None,
700 heartbeat_timeout_secs: None,
701 idle_timeout_ms: None,
702 writer_capacity: 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 max_message_size_bytes: None,
709 max_frame_size_bytes: None,
710 };
711
712 let keyed_quotas = vec![
713 (
714 OKX_RATE_LIMIT_KEY_SUBSCRIPTION[0].to_string(),
715 *OKX_WS_SUBSCRIPTION_QUOTA,
716 ),
717 (OKX_RATE_LIMIT_KEY_ORDER[0].to_string(), *OKX_WS_ORDER_QUOTA),
718 (
719 OKX_RATE_LIMIT_KEY_BATCH_ORDER[0].to_string(),
720 *OKX_WS_BATCH_ORDER_QUOTA,
721 ),
722 (
723 OKX_RATE_LIMIT_KEY_CANCEL[0].to_string(),
724 *OKX_WS_ORDER_QUOTA,
725 ),
726 (
727 OKX_RATE_LIMIT_KEY_BATCH_CANCEL[0].to_string(),
728 *OKX_WS_BATCH_ORDER_QUOTA,
729 ),
730 (
731 OKX_RATE_LIMIT_KEY_MASS_CANCEL[0].to_string(),
732 *OKX_WS_MASS_CANCEL_QUOTA,
733 ),
734 (OKX_RATE_LIMIT_KEY_AMEND[0].to_string(), *OKX_WS_ORDER_QUOTA),
735 (
736 OKX_RATE_LIMIT_KEY_BATCH_AMEND[0].to_string(),
737 *OKX_WS_BATCH_ORDER_QUOTA,
738 ),
739 (
740 OKX_RATE_LIMIT_KEY_ALGO_ORDER[0].to_string(),
741 *OKX_WS_ALGO_ORDER_QUOTA,
742 ),
743 (
744 OKX_RATE_LIMIT_KEY_ALGO_CANCEL[0].to_string(),
745 *OKX_WS_ALGO_CANCEL_QUOTA,
746 ),
747 ];
748
749 let client = WebSocketClient::builder()
750 .config(config)
751 .message_handler(message_handler)
752 .keyed_quotas(keyed_quotas)
753 .default_quota(*OKX_WS_CONNECTION_QUOTA)
754 .maybe_state_sink(self.socket_control.as_ref().map(|control| control.sink()))
755 .connect()
756 .await?;
757
758 let connection_mode = client.connection_mode_atomic();
759 let reconnect_handle = client.reconnect_handle();
760
761 let (msg_tx, rx) = tokio::sync::mpsc::unbounded_channel::<OKXWsMessage>();
762
763 self.out_rx = Some(Arc::new(rx));
764
765 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
766
767 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
769 anyhow::bail!("Failed to send WebSocket client to handler: {e}");
770 }
771
772 *self.cmd_tx.write().await = cmd_tx.clone();
773
774 self.connection_mode.store(connection_mode);
776
777 let signal = self.signal.clone();
778 let auth_tracker = self.auth_tracker.clone();
779 let subscriptions_state = self.subscriptions_state.clone();
780 let clock = self.clock;
781
782 let handler_task = {
783 let auth_tracker = auth_tracker.clone();
784 let signal = signal.clone();
785 let credential = self.credential.clone();
786 let cmd_tx_for_reconnect = cmd_tx.clone();
787 let subscriptions_bare = self.subscriptions_bare.clone();
788 let subscriptions_inst_type = self.subscriptions_inst_type.clone();
789 let subscriptions_inst_family = self.subscriptions_inst_family.clone();
790 let subscriptions_inst_id = self.subscriptions_inst_id.clone();
791 let cmd_tx_for_auth = Arc::clone(&self.cmd_tx);
792 let auth_tracker_for_auth = self.auth_tracker.clone();
793 let clock_for_auth = self.clock;
794 let auth_timeout_secs = self.auth_timeout_secs;
795 let reconnect_auth_generation = Arc::new(AtomicU64::new(0));
796 let handler_spawner_for_auth = handler_spawner.clone();
797 let mut has_reconnected = false;
798
799 async move {
800 let mut handler = OKXWsFeedHandler::new(
801 signal.clone(),
802 cmd_rx,
803 raw_rx,
804 msg_tx,
805 auth_tracker.clone(),
806 subscriptions_state.clone(),
807 clock,
808 );
809
810 let resubscribe_all = || {
811 let args = subscription_args(
812 &subscriptions_inst_type,
813 &subscriptions_inst_family,
814 &subscriptions_inst_id,
815 &subscriptions_bare,
816 );
817
818 for chunk in subscription_arg_batches(&args) {
819 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
820 args: chunk.to_vec(),
821 }) {
822 log::error!("Failed to send resubscribe command: error={e}");
823 }
824 }
825 };
826
827 loop {
828 let message = tokio::select! {
829 biased;
830 () = handler_abort.cancelled() => {
831 log::debug!("Handler task aborted");
832 break;
833 }
834 message = handler.next() => message,
835 };
836
837 match message {
838 Some(OKXWsMessage::Reconnected) => {
839 if signal.load(Ordering::Acquire) {
840 continue;
841 }
842
843 has_reconnected = true;
844
845 subscriptions_state.reset_after_reconnect();
846
847 if let Some(credential) = credential.clone() {
848 log::debug!("Re-authenticating after reconnection");
849 let generation =
850 reconnect_auth_generation.fetch_add(1, Ordering::AcqRel) + 1;
851 let signal = signal.clone();
852 let abort = handler_abort.clone();
853 let generation_flag = Arc::clone(&reconnect_auth_generation);
854 let cmd_tx = Arc::clone(&cmd_tx_for_auth);
855 let auth_tracker = auth_tracker_for_auth.clone();
856
857 if let Err(e) = handler_spawner_for_auth.spawn(async move {
858 retry_reconnect_authentication(
859 credential,
860 auth_tracker,
861 cmd_tx,
862 clock_for_auth,
863 auth_timeout_secs,
864 signal,
865 abort,
866 generation_flag,
867 generation,
868 )
869 .await;
870 }) {
871 log::error!(
872 "Failed to spawn re-authentication retry task: error={e}"
873 );
874 }
875 }
876
877 if credential.is_none() {
880 log::debug!(
881 "No authentication required, resubscribing immediately"
882 );
883 resubscribe_all();
884 }
885
886 if handler.send(OKXWsMessage::Reconnected).is_err() {
888 log_receiver_dropped(&signal, "Reconnected");
889 break;
890 }
891 }
892 Some(OKXWsMessage::Authenticated) => {
893 if has_reconnected {
894 resubscribe_all();
895 }
896 }
897 Some(msg) => {
898 if handler.send(msg).is_err() {
899 log_receiver_dropped(&signal, "message");
900 break;
901 }
902 }
903 None => {
904 if handler.is_stopped() {
905 log::debug!("Stop signal received, ending message processing");
906 break;
907 }
908 log::debug!("WebSocket stream closed");
909 break;
910 }
911 }
912 }
913
914 log::debug!("Handler task exiting");
915 }
916 };
917
918 if let Err(e) = handler_spawner.spawn(handler_task) {
919 self.out_rx = None;
920 anyhow::bail!("Failed to register WebSocket handler task: {e}");
921 }
922
923 if let Some(control) = &self.socket_control {
924 control.register(move || reconnect_handle.request_reconnect());
925 }
926 log::debug!("Sent WebSocket client to handler");
927
928 if self.credential.is_some()
929 && let Err(e) = self.authenticate().await
930 {
931 self.handler_tasks.begin_shutdown();
932 self.request_close().await;
933 let shutdown_result = self.close_stream_task(Duration::from_secs(2)).await;
934
935 if let Some(control) = &self.socket_control {
936 control.deregister();
937 }
938 self.out_rx = None;
939
940 match shutdown_result {
941 Ok(()) => anyhow::bail!("Authentication failed: {e}"),
942 Err(shutdown_error) => anyhow::bail!(
943 "Authentication failed: {e}; handler shutdown failed: {shutdown_error}"
944 ),
945 }
946 }
947
948 rollback.disarm();
949 Ok(())
950 }
951
952 async fn authenticate(&self) -> Result<(), Error> {
954 let credential = self.credential.as_ref().ok_or_else(|| {
955 Error::Io(std::io::Error::other(
956 "API credentials not available to authenticate",
957 ))
958 })?;
959
960 match authenticate_session(
961 credential,
962 &self.auth_tracker,
963 &self.cmd_tx,
964 self.clock,
965 self.auth_timeout_secs,
966 )
967 .await
968 {
969 Ok(()) => Ok(()),
970 Err(e) if auth_attempt_superseded(&e) => {
971 if self
974 .auth_tracker
975 .wait_for_authenticated(Duration::from_secs(self.auth_timeout_secs))
976 .await
977 {
978 Ok(())
979 } else {
980 Err(e)
981 }
982 }
983 Err(e) => Err(e),
984 }
985 }
986
987 pub fn stream(&mut self) -> impl Stream<Item = OKXWsMessage> + 'static {
995 let rx = self
996 .out_rx
997 .take()
998 .expect("Data stream receiver already taken or not connected");
999 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
1000 async_stream::stream! {
1001 while let Some(data) = rx.recv().await {
1002 yield data;
1003 }
1004 }
1005 }
1006
1007 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), OKXWsError> {
1013 let timeout = time::Duration::from_secs_f64(timeout_secs);
1014
1015 time::timeout(timeout, async {
1016 while !self.is_active() {
1017 time::sleep(time::Duration::from_millis(10)).await;
1018 }
1019 })
1020 .await
1021 .map_err(|_| {
1022 OKXWsError::ClientError(format!(
1023 "WebSocket connection timeout after {timeout_secs} seconds"
1024 ))
1025 })?;
1026
1027 Ok(())
1028 }
1029
1030 pub(crate) fn begin_shutdown(&self) {
1031 self.handler_tasks.begin_shutdown();
1032 self.signal.store(true, Ordering::Release);
1033
1034 let handler_abort = self.handler_abort.lock().clone();
1035 handler_abort.cancel();
1036 }
1037
1038 pub(crate) async fn request_close(&self) {
1040 self.signal.store(true, Ordering::Release);
1041
1042 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
1043 log::debug!("Handler channel closed before disconnect command was sent: {e}");
1044 } else {
1045 log::debug!("Sent disconnect command to handler");
1046 }
1047 }
1048
1049 pub async fn close(&mut self) -> Result<(), Error> {
1056 let connect_lock = Arc::clone(&self.connect_lock);
1057 let _connect_guard = connect_lock.lock().await;
1058
1059 self.close_locked().await
1060 }
1061
1062 async fn close_locked(&self) -> Result<(), Error> {
1063 log::debug!("Starting close process");
1064
1065 self.handler_tasks.begin_shutdown();
1066 self.request_close().await;
1067
1068 let task_result = self.close_stream_task(Duration::from_secs(2)).await;
1069
1070 self.index_pair_subscribers.clear();
1074
1075 if let Some(control) = &self.socket_control {
1076 control.deregister();
1077 }
1078
1079 log::debug!("Close process completed");
1080
1081 task_result
1082 }
1083
1084 async fn close_stream_task(&self, timeout: Duration) -> Result<(), Error> {
1085 match self.handler_tasks.finish_shutdown(timeout, timeout).await {
1086 Ok(()) => Ok(()),
1087 Err(error @ TaskShutdownError::Timeout { .. }) => Err(Error::Io(std::io::Error::new(
1088 std::io::ErrorKind::TimedOut,
1089 format!("Timed out joining WebSocket handler task after abort: {error}"),
1090 ))),
1091 Err(e) => Err(Error::Io(std::io::Error::other(format!(
1092 "WebSocket handler shutdown failed: {e}"
1093 )))),
1094 }
1095 }
1096
1097 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<OKXWsChannel> {
1099 let symbol = instrument_id.symbol.inner();
1100 let mut channels = Vec::new();
1101
1102 for entry in self.subscriptions_inst_id.iter() {
1103 let (channel, instruments) = entry.pair();
1104 if instruments.contains(&symbol) {
1105 channels.push(channel.clone());
1106 }
1107 }
1108
1109 channels
1110 }
1111
1112 fn generate_unique_request_id(&self) -> String {
1113 self.request_id_counter
1114 .fetch_add(1, Ordering::SeqCst)
1115 .to_string()
1116 }
1117
1118 async fn subscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1119 self.cmd_tx
1121 .read()
1122 .await
1123 .send(HandlerCommand::Subscribe { args: args.clone() })
1124 .map_err(|e| {
1125 OKXWsError::ClientError(format!("Failed to send subscribe command: {e}"))
1126 })?;
1127
1128 self.record_subscriptions(&args);
1129 Ok(())
1130 }
1131
1132 fn record_subscriptions(&self, args: &[OKXSubscriptionArg]) {
1133 for arg in args {
1134 let topic = topic_from_subscription_arg(arg);
1135 self.subscriptions_state.mark_subscribe(&topic);
1136
1137 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1139 self.subscriptions_bare.insert(arg.channel.clone(), true);
1140 } else {
1141 if let Some(inst_type) = &arg.inst_type {
1142 self.subscriptions_inst_type
1143 .entry(arg.channel.clone())
1144 .or_default()
1145 .insert(*inst_type);
1146 }
1147
1148 if let Some(inst_family) = &arg.inst_family {
1149 self.subscriptions_inst_family
1150 .entry(arg.channel.clone())
1151 .or_default()
1152 .insert(*inst_family);
1153 }
1154
1155 if let Some(inst_id) = &arg.inst_id {
1156 self.subscriptions_inst_id
1157 .entry(arg.channel.clone())
1158 .or_default()
1159 .insert(*inst_id);
1160 }
1161 }
1162 }
1163 }
1164
1165 #[expect(clippy::collapsible_if)]
1166 async fn unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1167 self.cmd_tx
1169 .read()
1170 .await
1171 .send(HandlerCommand::Unsubscribe { args: args.clone() })
1172 .map_err(|e| {
1173 OKXWsError::ClientError(format!("Failed to send unsubscribe command: {e}"))
1174 })?;
1175
1176 for arg in &args {
1177 let topic = topic_from_subscription_arg(arg);
1178 self.subscriptions_state.mark_unsubscribe(&topic);
1179
1180 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1181 self.subscriptions_bare.remove(&arg.channel);
1182 } else {
1183 if let Some(inst_type) = &arg.inst_type {
1184 if let Some(mut entry) = self.subscriptions_inst_type.get_mut(&arg.channel) {
1185 entry.remove(inst_type);
1186 if entry.is_empty() {
1187 drop(entry);
1188 self.subscriptions_inst_type.remove(&arg.channel);
1189 }
1190 }
1191 }
1192
1193 if let Some(inst_family) = &arg.inst_family {
1194 if let Some(mut entry) = self.subscriptions_inst_family.get_mut(&arg.channel) {
1195 entry.remove(inst_family);
1196 if entry.is_empty() {
1197 drop(entry);
1198 self.subscriptions_inst_family.remove(&arg.channel);
1199 }
1200 }
1201 }
1202
1203 if let Some(inst_id) = &arg.inst_id {
1204 if let Some(mut entry) = self.subscriptions_inst_id.get_mut(&arg.channel) {
1205 entry.remove(inst_id);
1206 if entry.is_empty() {
1207 drop(entry);
1208 self.subscriptions_inst_id.remove(&arg.channel);
1209 }
1210 }
1211 }
1212 }
1213 }
1214
1215 Ok(())
1216 }
1217
1218 async fn subscribe_inst_id(
1219 &self,
1220 channel: OKXWsChannel,
1221 inst_id: Ustr,
1222 ) -> Result<(), OKXWsError> {
1223 self.subscribe(vec![OKXSubscriptionArg {
1224 channel,
1225 inst_type: None,
1226 inst_family: None,
1227 inst_id: Some(inst_id),
1228 }])
1229 .await
1230 }
1231
1232 async fn unsubscribe_inst_id(
1233 &self,
1234 channel: OKXWsChannel,
1235 inst_id: Ustr,
1236 ) -> Result<(), OKXWsError> {
1237 self.unsubscribe(vec![OKXSubscriptionArg {
1238 channel,
1239 inst_type: None,
1240 inst_family: None,
1241 inst_id: Some(inst_id),
1242 }])
1243 .await
1244 }
1245
1246 pub async fn unsubscribe_all(&self) -> Result<(), OKXWsError> {
1255 let all_args = subscription_args(
1256 &self.subscriptions_inst_type,
1257 &self.subscriptions_inst_family,
1258 &self.subscriptions_inst_id,
1259 &self.subscriptions_bare,
1260 );
1261
1262 if all_args.is_empty() {
1263 log::debug!("No active subscriptions to unsubscribe from");
1264 return Ok(());
1265 }
1266
1267 log::debug!("Batched unsubscribe from {} channels", all_args.len());
1268
1269 for chunk in subscription_arg_batches(&all_args) {
1270 self.unsubscribe(chunk.to_vec()).await?;
1271 }
1272
1273 self.index_pair_subscribers.clear();
1277
1278 Ok(())
1279 }
1280
1281 pub async fn subscribe_instruments(
1293 &self,
1294 instrument_type: OKXInstrumentType,
1295 ) -> Result<(), OKXWsError> {
1296 let arg = OKXSubscriptionArg {
1297 channel: OKXWsChannel::Instruments,
1298 inst_type: Some(instrument_type),
1299 inst_family: None,
1300 inst_id: None,
1301 };
1302 self.subscribe(vec![arg]).await
1303 }
1304
1305 pub async fn subscribe_instrument(
1319 &self,
1320 instrument_id: InstrumentId,
1321 ) -> Result<(), OKXWsError> {
1322 let inst_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
1323 log::debug!("Subscribing to instrument type {inst_type:?} for {instrument_id}");
1324 self.subscribe_instruments(inst_type).await
1325 }
1326
1327 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1336 self.subscribe_book_with_depth(instrument_id, 0).await
1337 }
1338
1339 pub async fn subscribe_book_rpi(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1345 self.subscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1346 .await
1347 }
1348
1349 pub(crate) async fn subscribe_book_channel(
1350 &self,
1351 instrument_id: InstrumentId,
1352 channel: OKXBookChannel,
1353 cancel: CancellationToken,
1354 gate: SnapshotGate,
1355 ) -> Result<(), OKXWsError> {
1356 let (completion, receiver) = tokio::sync::oneshot::channel();
1357 let sender = self.cmd_tx.read().await;
1358
1359 if cancel.is_cancelled() {
1360 return Ok(());
1361 }
1362
1363 let subscription = OKXSubscriptionArg {
1364 channel: ws_channel_for_book(channel),
1365 inst_type: None,
1366 inst_family: None,
1367 inst_id: Some(instrument_id.symbol.inner()),
1368 };
1369
1370 self.record_subscriptions(std::slice::from_ref(&subscription));
1371 sender
1372 .send(HandlerCommand::SubscribeBook {
1373 subscription,
1374 cancel: cancel.clone(),
1375 gate,
1376 completion,
1377 })
1378 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?;
1379
1380 drop(sender);
1381 tokio::select! {
1382 biased;
1383 () = cancel.cancelled() => Ok(()),
1384 result = receiver => result.map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?,
1385 }
1386 }
1387
1388 pub(crate) async fn resubscribe_book_channel(
1390 &self,
1391 instrument_id: InstrumentId,
1392 channel: OKXBookChannel,
1393 cancel: CancellationToken,
1394 gate: SnapshotGate,
1395 ) -> Result<(), OKXWsError> {
1396 let (completion, receiver) = tokio::sync::oneshot::channel();
1397 let sender = self.cmd_tx.read().await;
1398
1399 if cancel.is_cancelled() {
1400 return Err(OKXWsError::ClientError("Book recovery canceled".into()));
1401 }
1402
1403 sender
1404 .send(HandlerCommand::Resubscribe {
1405 subscription: OKXSubscriptionArg {
1406 channel: ws_channel_for_book(channel),
1407 inst_type: None,
1408 inst_family: None,
1409 inst_id: Some(instrument_id.symbol.inner()),
1410 },
1411 cancel,
1412 gate,
1413 completion,
1414 })
1415 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?;
1416
1417 drop(sender);
1418 receiver
1419 .await
1420 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))?
1421 }
1422
1423 pub async fn subscribe_book_depth5(
1435 &self,
1436 instrument_id: InstrumentId,
1437 ) -> Result<(), OKXWsError> {
1438 self.subscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1439 .await
1440 }
1441
1442 pub async fn subscribe_book50_l2_tbt(
1454 &self,
1455 instrument_id: InstrumentId,
1456 ) -> Result<(), OKXWsError> {
1457 self.subscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1458 .await
1459 }
1460
1461 pub async fn subscribe_book_l2_tbt(
1473 &self,
1474 instrument_id: InstrumentId,
1475 ) -> Result<(), OKXWsError> {
1476 self.subscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1477 .await
1478 }
1479
1480 pub async fn subscribe_book_with_depth(
1494 &self,
1495 instrument_id: InstrumentId,
1496 depth: u16,
1497 ) -> anyhow::Result<()> {
1498 let vip = self.vip_level();
1499
1500 if !matches!(depth, 0 | 50 | 400) {
1501 anyhow::bail!("Invalid depth {depth}, must be 0, 50, or 400");
1502 }
1503
1504 if depth == 50 && vip < OKXVipLevel::Vip4 {
1505 anyhow::bail!("VIP level {vip} insufficient for 50 depth subscription (requires VIP4)");
1506 }
1507
1508 let channel = select_book_channel(depth as usize, vip);
1509 self.subscribe_inst_id(ws_channel_for_book(channel), instrument_id.symbol.inner())
1510 .await?;
1511 Ok(())
1512 }
1513
1514 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1527 self.subscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1528 .await
1529 }
1530
1531 pub async fn subscribe_trades(
1545 &self,
1546 instrument_id: InstrumentId,
1547 aggregated: bool,
1548 ) -> Result<(), OKXWsError> {
1549 let channel = if aggregated {
1550 OKXWsChannel::TradesAll
1551 } else {
1552 OKXWsChannel::Trades
1553 };
1554 self.subscribe_inst_id(channel, instrument_id.symbol.inner())
1555 .await
1556 }
1557
1558 pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1570 self.subscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1571 .await
1572 }
1573
1574 pub async fn subscribe_mark_prices(
1586 &self,
1587 instrument_id: InstrumentId,
1588 ) -> Result<(), OKXWsError> {
1589 self.subscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1590 .await
1591 }
1592
1593 pub async fn subscribe_index_prices(
1605 &self,
1606 instrument_id: InstrumentId,
1607 ) -> Result<(), OKXWsError> {
1608 let symbol = instrument_id.symbol.inner();
1610 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1611 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1612 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1613
1614 let _guard = self.index_pair_transition.lock().await;
1620
1621 let is_first = {
1626 let mut count = self.index_pair_subscribers.entry(base_pair).or_insert(0);
1627 *count += 1;
1628 *count == 1
1629 };
1630
1631 if !is_first {
1632 return Ok(());
1633 }
1634
1635 let arg = OKXSubscriptionArg {
1636 channel: OKXWsChannel::IndexTickers,
1637 inst_type: None,
1638 inst_family: None,
1639 inst_id: Some(base_pair),
1640 };
1641
1642 match self.subscribe(vec![arg]).await {
1643 Ok(()) => Ok(()),
1644 Err(e) => {
1645 self.index_pair_subscribers.remove(&base_pair);
1654 Err(e)
1655 }
1656 }
1657 }
1658
1659 pub async fn subscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1672 let arg = OKXSubscriptionArg {
1673 channel: OKXWsChannel::OptionSummary,
1674 inst_type: None,
1675 inst_family: Some(inst_family),
1676 inst_id: None,
1677 };
1678 self.subscribe(vec![arg]).await
1679 }
1680
1681 pub async fn subscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1691 let arg = OKXSubscriptionArg {
1692 channel: OKXWsChannel::EventContractMarkets,
1693 inst_type: Some(OKXInstrumentType::Events),
1694 inst_family: None,
1695 inst_id: None,
1696 };
1697 self.subscribe(vec![arg]).await
1698 }
1699
1700 pub fn option_greeks_subs(&self) -> &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>> {
1704 &self.option_greeks_subs
1705 }
1706
1707 pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1710 let both: AHashSet<OKXGreeksType> =
1711 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
1712 self.option_greeks_subs.insert(instrument_id, both);
1713 }
1714
1715 pub fn add_option_greeks_sub_with_conventions(
1718 &self,
1719 instrument_id: InstrumentId,
1720 conventions: AHashSet<OKXGreeksType>,
1721 ) {
1722 let set = if conventions.is_empty() {
1723 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
1724 } else {
1725 conventions
1726 };
1727 self.option_greeks_subs.insert(instrument_id, set);
1728 }
1729
1730 pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1732 self.option_greeks_subs.remove(instrument_id);
1733 }
1734
1735 pub async fn subscribe_funding_rates(
1747 &self,
1748 instrument_id: InstrumentId,
1749 ) -> Result<(), OKXWsError> {
1750 self.subscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1751 .await
1752 }
1753
1754 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
1766 let channel = bar_spec_as_okx_channel(bar_type.spec())
1768 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1769 self.subscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
1770 .await
1771 }
1772
1773 pub async fn unsubscribe_instruments(
1779 &self,
1780 instrument_type: OKXInstrumentType,
1781 ) -> Result<(), OKXWsError> {
1782 let arg = OKXSubscriptionArg {
1783 channel: OKXWsChannel::Instruments,
1784 inst_type: Some(instrument_type),
1785 inst_family: None,
1786 inst_id: None,
1787 };
1788 self.unsubscribe(vec![arg]).await
1789 }
1790
1791 #[allow(
1801 clippy::unused_async,
1802 clippy::unused_async_trait_impl,
1803 reason = "async signature is kept for parity with the other unsubscribe methods and callers"
1804 )]
1805 pub async fn unsubscribe_instrument(
1806 &self,
1807 instrument_id: InstrumentId,
1808 ) -> Result<(), OKXWsError> {
1809 log::debug!("Instrument unsubscribe is a no-op (shared per-type channel): {instrument_id}");
1810 Ok(())
1811 }
1812
1813 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1819 self.unsubscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1820 .await
1821 }
1822
1823 pub async fn unsubscribe_book_rpi(
1829 &self,
1830 instrument_id: InstrumentId,
1831 ) -> Result<(), OKXWsError> {
1832 self.unsubscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1833 .await
1834 }
1835
1836 pub async fn unsubscribe_book_depth5(
1842 &self,
1843 instrument_id: InstrumentId,
1844 ) -> Result<(), OKXWsError> {
1845 self.unsubscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1846 .await
1847 }
1848
1849 pub async fn unsubscribe_book50_l2_tbt(
1855 &self,
1856 instrument_id: InstrumentId,
1857 ) -> Result<(), OKXWsError> {
1858 self.unsubscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1859 .await
1860 }
1861
1862 pub async fn unsubscribe_book_l2_tbt(
1868 &self,
1869 instrument_id: InstrumentId,
1870 ) -> Result<(), OKXWsError> {
1871 self.unsubscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1872 .await
1873 }
1874
1875 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1881 self.unsubscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1882 .await
1883 }
1884
1885 pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1891 self.unsubscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1892 .await
1893 }
1894
1895 pub async fn unsubscribe_mark_prices(
1901 &self,
1902 instrument_id: InstrumentId,
1903 ) -> Result<(), OKXWsError> {
1904 self.unsubscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1905 .await
1906 }
1907
1908 pub async fn unsubscribe_index_prices(
1921 &self,
1922 instrument_id: InstrumentId,
1923 ) -> Result<(), OKXWsError> {
1924 let symbol = instrument_id.symbol.inner();
1925 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1926 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1927 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1928
1929 let _guard = self.index_pair_transition.lock().await;
1932
1933 let is_last = {
1934 let Some(mut count) = self.index_pair_subscribers.get_mut(&base_pair) else {
1935 return Ok(());
1937 };
1938 *count = count.saturating_sub(1);
1939 *count == 0
1940 };
1941
1942 if !is_last {
1943 return Ok(());
1944 }
1945
1946 self.index_pair_subscribers
1947 .remove_if(&base_pair, |_, count| *count == 0);
1948
1949 let arg = OKXSubscriptionArg {
1950 channel: OKXWsChannel::IndexTickers,
1951 inst_type: None,
1952 inst_family: None,
1953 inst_id: Some(base_pair),
1954 };
1955 self.unsubscribe(vec![arg]).await
1956 }
1957
1958 pub async fn unsubscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1964 let arg = OKXSubscriptionArg {
1965 channel: OKXWsChannel::OptionSummary,
1966 inst_type: None,
1967 inst_family: Some(inst_family),
1968 inst_id: None,
1969 };
1970 self.unsubscribe(vec![arg]).await
1971 }
1972
1973 pub async fn unsubscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1979 let arg = OKXSubscriptionArg {
1980 channel: OKXWsChannel::EventContractMarkets,
1981 inst_type: Some(OKXInstrumentType::Events),
1982 inst_family: None,
1983 inst_id: None,
1984 };
1985 self.unsubscribe(vec![arg]).await
1986 }
1987
1988 pub async fn unsubscribe_funding_rates(
1994 &self,
1995 instrument_id: InstrumentId,
1996 ) -> Result<(), OKXWsError> {
1997 self.unsubscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1998 .await
1999 }
2000
2001 pub async fn unsubscribe_trades(
2007 &self,
2008 instrument_id: InstrumentId,
2009 aggregated: bool,
2010 ) -> Result<(), OKXWsError> {
2011 let channel = if aggregated {
2012 OKXWsChannel::TradesAll
2013 } else {
2014 OKXWsChannel::Trades
2015 };
2016 self.unsubscribe_inst_id(channel, instrument_id.symbol.inner())
2017 .await
2018 }
2019
2020 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
2026 let channel = bar_spec_as_okx_channel(bar_type.spec())
2027 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2028 self.unsubscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
2029 .await
2030 }
2031
2032 pub async fn subscribe_orders(
2038 &self,
2039 instrument_type: OKXInstrumentType,
2040 ) -> Result<(), OKXWsError> {
2041 let arg = OKXSubscriptionArg {
2042 channel: OKXWsChannel::Orders,
2043 inst_type: Some(instrument_type),
2044 inst_family: None,
2045 inst_id: None,
2046 };
2047 self.subscribe(vec![arg]).await
2048 }
2049
2050 pub async fn unsubscribe_orders(
2056 &self,
2057 instrument_type: OKXInstrumentType,
2058 ) -> Result<(), OKXWsError> {
2059 let arg = OKXSubscriptionArg {
2060 channel: OKXWsChannel::Orders,
2061 inst_type: Some(instrument_type),
2062 inst_family: None,
2063 inst_id: None,
2064 };
2065 self.unsubscribe(vec![arg]).await
2066 }
2067
2068 pub async fn subscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2074 let arg = OKXSubscriptionArg {
2075 channel: OKXWsChannel::SprdOrders,
2076 inst_type: None,
2077 inst_family: None,
2078 inst_id: None,
2079 };
2080 self.subscribe(vec![arg]).await
2081 }
2082
2083 pub async fn unsubscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2089 let arg = OKXSubscriptionArg {
2090 channel: OKXWsChannel::SprdOrders,
2091 inst_type: None,
2092 inst_family: None,
2093 inst_id: None,
2094 };
2095 self.unsubscribe(vec![arg]).await
2096 }
2097
2098 pub async fn subscribe_spread_quotes(
2104 &self,
2105 instrument_id: InstrumentId,
2106 ) -> Result<(), OKXWsError> {
2107 self.subscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2108 .await
2109 }
2110
2111 pub async fn subscribe_spread_book(
2117 &self,
2118 instrument_id: InstrumentId,
2119 ) -> Result<(), OKXWsError> {
2120 self.subscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2121 .await
2122 }
2123
2124 pub async fn subscribe_spread_trades(
2130 &self,
2131 instrument_id: InstrumentId,
2132 ) -> Result<(), OKXWsError> {
2133 self.subscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2134 .await
2135 }
2136
2137 pub async fn unsubscribe_spread_quotes(
2143 &self,
2144 instrument_id: InstrumentId,
2145 ) -> Result<(), OKXWsError> {
2146 self.unsubscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2147 .await
2148 }
2149
2150 pub async fn unsubscribe_spread_book(
2156 &self,
2157 instrument_id: InstrumentId,
2158 ) -> Result<(), OKXWsError> {
2159 self.unsubscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2160 .await
2161 }
2162
2163 pub async fn unsubscribe_spread_trades(
2169 &self,
2170 instrument_id: InstrumentId,
2171 ) -> Result<(), OKXWsError> {
2172 self.unsubscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2173 .await
2174 }
2175
2176 pub async fn subscribe_orders_algo(
2182 &self,
2183 instrument_type: OKXInstrumentType,
2184 ) -> Result<(), OKXWsError> {
2185 let arg = OKXSubscriptionArg {
2186 channel: OKXWsChannel::OrdersAlgo,
2187 inst_type: Some(instrument_type),
2188 inst_family: None,
2189 inst_id: None,
2190 };
2191 self.subscribe(vec![arg]).await
2192 }
2193
2194 pub async fn unsubscribe_orders_algo(
2200 &self,
2201 instrument_type: OKXInstrumentType,
2202 ) -> Result<(), OKXWsError> {
2203 let arg = OKXSubscriptionArg {
2204 channel: OKXWsChannel::OrdersAlgo,
2205 inst_type: Some(instrument_type),
2206 inst_family: None,
2207 inst_id: None,
2208 };
2209 self.unsubscribe(vec![arg]).await
2210 }
2211
2212 pub async fn subscribe_algo_advance(
2218 &self,
2219 instrument_type: OKXInstrumentType,
2220 ) -> Result<(), OKXWsError> {
2221 let arg = OKXSubscriptionArg {
2222 channel: OKXWsChannel::AlgoAdvance,
2223 inst_type: Some(instrument_type),
2224 inst_family: None,
2225 inst_id: None,
2226 };
2227 self.subscribe(vec![arg]).await
2228 }
2229
2230 pub async fn unsubscribe_algo_advance(
2236 &self,
2237 instrument_type: OKXInstrumentType,
2238 ) -> Result<(), OKXWsError> {
2239 let arg = OKXSubscriptionArg {
2240 channel: OKXWsChannel::AlgoAdvance,
2241 inst_type: Some(instrument_type),
2242 inst_family: None,
2243 inst_id: None,
2244 };
2245 self.unsubscribe(vec![arg]).await
2246 }
2247
2248 pub async fn subscribe_account(&self) -> Result<(), OKXWsError> {
2254 let arg = OKXSubscriptionArg {
2255 channel: OKXWsChannel::Account,
2256 inst_type: None,
2257 inst_family: None,
2258 inst_id: None,
2259 };
2260 self.subscribe(vec![arg]).await
2261 }
2262
2263 pub async fn unsubscribe_account(&self) -> Result<(), OKXWsError> {
2269 let arg = OKXSubscriptionArg {
2270 channel: OKXWsChannel::Account,
2271 inst_type: None,
2272 inst_family: None,
2273 inst_id: None,
2274 };
2275 self.unsubscribe(vec![arg]).await
2276 }
2277
2278 pub async fn subscribe_positions(
2288 &self,
2289 inst_type: OKXInstrumentType,
2290 ) -> Result<(), OKXWsError> {
2291 let arg = OKXSubscriptionArg {
2292 channel: OKXWsChannel::Positions,
2293 inst_type: Some(inst_type),
2294 inst_family: None,
2295 inst_id: None,
2296 };
2297 self.subscribe(vec![arg]).await
2298 }
2299
2300 pub async fn unsubscribe_positions(
2306 &self,
2307 inst_type: OKXInstrumentType,
2308 ) -> Result<(), OKXWsError> {
2309 let arg = OKXSubscriptionArg {
2310 channel: OKXWsChannel::Positions,
2311 inst_type: Some(inst_type),
2312 inst_family: None,
2313 inst_id: None,
2314 };
2315 self.unsubscribe(vec![arg]).await
2316 }
2317
2318 pub async fn subscribe_liquidation_warning(
2328 &self,
2329 instrument_type: OKXInstrumentType,
2330 ) -> Result<(), OKXWsError> {
2331 let arg = OKXSubscriptionArg {
2332 channel: OKXWsChannel::LiquidationWarning,
2333 inst_type: Some(instrument_type),
2334 inst_family: None,
2335 inst_id: None,
2336 };
2337 self.subscribe(vec![arg]).await
2338 }
2339
2340 pub async fn unsubscribe_liquidation_warning(
2346 &self,
2347 instrument_type: OKXInstrumentType,
2348 ) -> Result<(), OKXWsError> {
2349 let arg = OKXSubscriptionArg {
2350 channel: OKXWsChannel::LiquidationWarning,
2351 inst_type: Some(instrument_type),
2352 inst_family: None,
2353 inst_id: None,
2354 };
2355 self.unsubscribe(vec![arg]).await
2356 }
2357
2358 async fn ws_batch_place_orders(
2364 &self,
2365 args: Vec<Value>,
2366 client_order_ids: Vec<ClientOrderId>,
2367 ) -> Result<(), OKXWsError> {
2368 let request_id = self.generate_unique_request_id();
2369 let request = OKXWsRequest::<Value> {
2370 id: Some(request_id.clone()),
2371 op: super::enums::OKXWsOperation::BatchOrders,
2372 exp_time: None,
2373 args,
2374 };
2375
2376 let payload = serde_json::to_string(&request)
2377 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch orders: {e}")))?;
2378
2379 let cmd = HandlerCommand::Send {
2380 payload,
2381 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_ORDER.to_vec()),
2382 request_id: Some(request_id),
2383 client_order_ids,
2384 op: Some(super::enums::OKXWsOperation::BatchOrders),
2385 };
2386
2387 self.send_cmd(cmd).await
2388 }
2389
2390 async fn ws_batch_cancel_orders(
2396 &self,
2397 args: Vec<Value>,
2398 client_order_ids: Vec<ClientOrderId>,
2399 ) -> Result<(), OKXWsError> {
2400 let request_id = self.generate_unique_request_id();
2401 let request = OKXWsRequest::<Value> {
2402 id: Some(request_id.clone()),
2403 op: super::enums::OKXWsOperation::BatchCancelOrders,
2404 exp_time: None,
2405 args,
2406 };
2407
2408 let payload = serde_json::to_string(&request)
2409 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch cancel: {e}")))?;
2410
2411 let cmd = HandlerCommand::Send {
2412 payload,
2413 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_CANCEL.to_vec()),
2414 request_id: Some(request_id),
2415 client_order_ids,
2416 op: Some(super::enums::OKXWsOperation::BatchCancelOrders),
2417 };
2418
2419 self.send_cmd(cmd).await
2420 }
2421
2422 async fn ws_batch_amend_orders(
2428 &self,
2429 args: Vec<Value>,
2430 client_order_ids: Vec<ClientOrderId>,
2431 ) -> Result<(), OKXWsError> {
2432 let request_id = self.generate_unique_request_id();
2433 let request = OKXWsRequest::<Value> {
2434 id: Some(request_id.clone()),
2435 op: super::enums::OKXWsOperation::BatchAmendOrders,
2436 exp_time: None,
2437 args,
2438 };
2439
2440 let payload = serde_json::to_string(&request)
2441 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch amend: {e}")))?;
2442
2443 let cmd = HandlerCommand::Send {
2444 payload,
2445 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_AMEND.to_vec()),
2446 request_id: Some(request_id),
2447 client_order_ids,
2448 op: Some(super::enums::OKXWsOperation::BatchAmendOrders),
2449 };
2450
2451 self.send_cmd(cmd).await
2452 }
2453
2454 #[expect(clippy::too_many_arguments)]
2466 pub async fn submit_order(
2467 &self,
2468 trader_id: TraderId,
2469 strategy_id: StrategyId,
2470 instrument_id: InstrumentId,
2471 td_mode: OKXTradeMode,
2472 client_order_id: ClientOrderId,
2473 order_side: OrderSide,
2474 order_type: OrderType,
2475 quantity: Quantity,
2476 time_in_force: Option<TimeInForce>,
2477 price: Option<Price>,
2478 trigger_price: Option<Price>,
2479 post_only: Option<bool>,
2480 reduce_only: Option<bool>,
2481 quote_quantity: Option<bool>,
2482 position_side: Option<PositionSide>,
2483 attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
2484 px_usd: Option<String>,
2485 px_vol: Option<String>,
2486 outcome: Option<String>,
2487 slippage_pct: Option<String>,
2488 rpi: Option<bool>,
2489 rpi_taker_access: Option<bool>,
2490 rpi_px_round: Option<bool>,
2491 ) -> Result<(), OKXWsError> {
2492 let rpi = rpi.unwrap_or(false);
2493
2494 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
2495 return Err(OKXWsError::ClientError(format!(
2496 "Unsupported order type: {order_type:?}",
2497 )));
2498 }
2499
2500 if let Some(tif) = time_in_force
2501 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
2502 {
2503 return Err(OKXWsError::ClientError(format!(
2504 "Unsupported time in force: {tif:?}",
2505 )));
2506 }
2507
2508 let mut builder = WsPostOrderParamsBuilder::default();
2509
2510 let inst_id_code = self
2511 .get_inst_id_code(&instrument_id.symbol.inner())
2512 .ok_or_else(|| {
2513 OKXWsError::ClientError(format!(
2514 "No instIdCode cached for {instrument_id}, cannot submit order"
2515 ))
2516 })?;
2517 builder.inst_id_code(inst_id_code);
2518
2519 builder.td_mode(td_mode);
2520 builder.cl_ord_id(client_order_id.as_str());
2521
2522 let (instrument_type, quote_currency) = {
2523 let instruments = self.instruments_cache.load();
2524 let symbol = instrument_id.symbol.inner();
2525 let instrument = instruments.get(&symbol).ok_or_else(|| {
2526 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2527 })?;
2528 let instrument_type = okx_instrument_type(instrument)
2529 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2530 (instrument_type, instrument.quote_currency())
2531 };
2532
2533 if instrument_type == OKXInstrumentType::Option
2535 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
2536 {
2537 return Err(OKXWsError::ClientError(
2538 "Market orders are not supported for OKX options, use Limit orders instead"
2539 .to_string(),
2540 ));
2541 }
2542
2543 match instrument_type {
2544 OKXInstrumentType::Spot | OKXInstrumentType::Margin => {
2545 builder.ccy(quote_currency.to_string());
2547 }
2548 OKXInstrumentType::Swap | OKXInstrumentType::Futures => {
2549 builder.ccy(quote_currency.to_string());
2551
2552 if position_side.is_none() {
2555 builder.pos_side(OKXPositionSide::Net);
2556 }
2557 }
2558 OKXInstrumentType::Option => {
2559 builder.ccy(quote_currency.to_string());
2560
2561 if position_side.is_none() {
2562 builder.pos_side(OKXPositionSide::Net);
2563 }
2564 }
2565 OKXInstrumentType::Events => {}
2566 _ => {
2567 builder.ccy(quote_currency.to_string());
2568
2569 if position_side.is_none() {
2570 builder.pos_side(OKXPositionSide::Net);
2571 }
2572 }
2573 }
2574
2575 if let Some(reduce_only) = okx_reduce_only_wire_value(
2576 instrument_type,
2577 td_mode,
2578 order_side,
2579 position_side,
2580 reduce_only,
2581 )
2582 .map_err(OKXWsError::ClientError)?
2583 {
2584 builder.reduce_only(reduce_only);
2585 }
2586
2587 if let Some(attach_algo_ords) = attach_algo_ords {
2588 builder.attach_algo_ords(attach_algo_ords);
2589 }
2590
2591 if instrument_type == OKXInstrumentType::Spot
2598 && order_type == OrderType::Market
2599 && td_mode == OKXTradeMode::Cash
2600 {
2601 match quote_quantity {
2602 Some(true) => {
2603 builder.tgt_ccy(OKXTargetCurrency::QuoteCcy);
2604 }
2605 Some(false) if order_side == OrderSide::Buy => {
2607 builder.tgt_ccy(OKXTargetCurrency::BaseCcy);
2608 }
2609 Some(false) | None => {}
2611 }
2612 }
2613
2614 builder.side(order_side);
2615
2616 if let Some(pos_side) = position_side {
2617 builder.pos_side(pos_side);
2618 }
2619
2620 if rpi && order_type != OrderType::Limit {
2624 return Err(OKXWsError::ClientError(
2625 "OKX RPI orders require a limit order".to_string(),
2626 ));
2627 }
2628
2629 let (okx_ord_type, price) = if rpi {
2630 (OKXOrderType::Rpi, price)
2631 } else if post_only.unwrap_or(false) {
2632 (OKXOrderType::PostOnly, price)
2633 } else if let Some(tif) = time_in_force {
2634 match (order_type, tif) {
2635 (OrderType::Market, TimeInForce::Fok) => {
2636 return Err(OKXWsError::ClientError(
2637 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string()
2638 ));
2639 }
2640 (OrderType::Market, TimeInForce::Ioc) => {
2641 if matches!(
2643 instrument_type,
2644 OKXInstrumentType::Spot | OKXInstrumentType::Option
2645 ) {
2646 (OKXOrderType::Market, price)
2647 } else {
2648 (OKXOrderType::OptimalLimitIoc, price)
2649 }
2650 }
2651 (OrderType::Limit, TimeInForce::Fok) => {
2652 if instrument_type == OKXInstrumentType::Option {
2654 (OKXOrderType::OpFok, price)
2655 } else {
2656 (OKXOrderType::Fok, price)
2657 }
2658 }
2659 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
2660 _ => (OKXOrderType::from(order_type), price),
2661 }
2662 } else {
2663 (OKXOrderType::from(order_type), price)
2664 };
2665
2666 log::debug!(
2667 "Order type mapping: order_type={order_type:?}, time_in_force={time_in_force:?}, post_only={post_only:?} -> okx_ord_type={okx_ord_type:?}"
2668 );
2669
2670 if instrument_type == OKXInstrumentType::Events && outcome.is_none() {
2671 return Err(OKXWsError::ClientError(
2672 "OKX event contract orders require `outcome`".to_string(),
2673 ));
2674 }
2675
2676 if let Some(outcome) = outcome {
2677 builder.outcome(outcome);
2678 }
2679
2680 if let Some(slippage) = slippage_pct {
2681 builder.slippage_pct(slippage);
2682 }
2683
2684 if let Some(rpi_taker_access) = rpi_taker_access {
2685 builder.rpi_taker_access(rpi_taker_access);
2686 }
2687
2688 if let Some(rpi_px_round) = rpi_px_round {
2689 builder.rpi_px_round(rpi_px_round);
2690 }
2691
2692 if let Some(trade_quote_ccy) =
2693 self.resolve_spot_trade_quote_ccy(instrument_type, instrument_id.symbol.inner())?
2694 {
2695 builder.trade_quote_ccy(trade_quote_ccy);
2696 }
2697
2698 builder.ord_type(okx_ord_type);
2699 builder.sz(quantity.to_string());
2700
2701 if let Some(usd) = px_usd {
2703 builder.px_usd(usd);
2704 } else if let Some(vol) = px_vol {
2705 builder.px_vol(vol);
2706 } else if let Some(tp) = trigger_price {
2707 builder.px(tp.to_string());
2708 } else if let Some(p) = price {
2709 builder.px(p.to_string());
2710 }
2711
2712 builder.tag(OKX_NAUTILUS_BROKER_ID);
2713
2714 let params = builder
2715 .build()
2716 .map_err(|e| OKXWsError::ClientError(format!("Build order params error: {e}")))?;
2717
2718 let request_id = self.generate_unique_request_id();
2719 let request = OKXWsRequest {
2720 id: Some(request_id.clone()),
2721 op: super::enums::OKXWsOperation::Order,
2722 exp_time: None,
2723 args: vec![params],
2724 };
2725
2726 let payload = serde_json::to_string(&request)
2727 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize order: {e}")))?;
2728
2729 let cl_ord_key = client_order_id.to_string();
2730 self.pending_orders.insert(
2731 cl_ord_key.clone(),
2732 PendingOrderInfo {
2733 trader_id,
2734 strategy_id,
2735 instrument_id,
2736 },
2737 );
2738
2739 let cmd = HandlerCommand::Send {
2740 payload,
2741 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ORDER.to_vec()),
2742 request_id: Some(request_id),
2743 client_order_ids: vec![client_order_id],
2744 op: Some(super::enums::OKXWsOperation::Order),
2745 };
2746
2747 let result = self.send_cmd(cmd).await;
2748
2749 if result.is_err() {
2750 self.pending_orders.remove(&cl_ord_key);
2751 }
2752
2753 result
2754 }
2755
2756 #[expect(clippy::too_many_arguments)]
2772 pub async fn modify_order(
2773 &self,
2774 trader_id: TraderId,
2775 strategy_id: StrategyId,
2776 instrument_id: InstrumentId,
2777 client_order_id: Option<ClientOrderId>,
2778 price: Option<Price>,
2779 quantity: Option<Quantity>,
2780 venue_order_id: Option<VenueOrderId>,
2781 new_px_usd: Option<String>,
2782 new_px_vol: Option<String>,
2783 rpi_taker_access: Option<bool>,
2784 rpi_px_round: Option<bool>,
2785 ) -> Result<(), OKXWsError> {
2786 let mut builder = WsAmendOrderParamsBuilder::default();
2787
2788 let inst_id_code = self
2789 .get_inst_id_code(&instrument_id.symbol.inner())
2790 .ok_or_else(|| {
2791 OKXWsError::ClientError(format!(
2792 "No instIdCode cached for {instrument_id}, cannot amend order"
2793 ))
2794 })?;
2795 builder.inst_id_code(inst_id_code);
2796
2797 if let Some(venue_order_id) = venue_order_id {
2798 builder.ord_id(venue_order_id.as_str());
2799 }
2800
2801 let cl_ord_key = client_order_id.map(|id| id.to_string());
2802
2803 if let Some(client_order_id) = client_order_id {
2804 builder.cl_ord_id(client_order_id.as_str());
2805 self.pending_amends.insert(
2806 client_order_id.to_string(),
2807 PendingOrderInfo {
2808 trader_id,
2809 strategy_id,
2810 instrument_id,
2811 },
2812 );
2813 }
2814
2815 if let Some(usd) = new_px_usd {
2817 builder.new_px_usd(usd);
2818 } else if let Some(vol) = new_px_vol {
2819 builder.new_px_vol(vol);
2820 } else if let Some(price) = price {
2821 builder.new_px(price.to_string());
2822 }
2823
2824 if let Some(quantity) = quantity {
2825 builder.new_sz(quantity.to_string());
2826 }
2827
2828 if let Some(rpi_taker_access) = rpi_taker_access {
2829 builder.rpi_taker_access(rpi_taker_access);
2830 }
2831
2832 if let Some(rpi_px_round) = rpi_px_round {
2833 builder.rpi_px_round(rpi_px_round);
2834 }
2835
2836 let params = builder
2837 .build()
2838 .map_err(|e| OKXWsError::ClientError(format!("Build amend params error: {e}")))?;
2839
2840 let request_id = self.generate_unique_request_id();
2841 let request = OKXWsRequest {
2842 id: Some(request_id.clone()),
2843 op: super::enums::OKXWsOperation::AmendOrder,
2844 exp_time: None,
2845 args: vec![params],
2846 };
2847
2848 let payload = serde_json::to_string(&request)
2849 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize amend: {e}")))?;
2850
2851 let cmd = HandlerCommand::Send {
2852 payload,
2853 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_AMEND.to_vec()),
2854 request_id: Some(request_id),
2855 client_order_ids: client_order_id.into_iter().collect(),
2856 op: Some(super::enums::OKXWsOperation::AmendOrder),
2857 };
2858
2859 let result = self.send_cmd(cmd).await;
2860
2861 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2862 self.pending_amends.remove(key);
2863 }
2864
2865 result
2866 }
2867
2868 pub async fn cancel_order(
2879 &self,
2880 trader_id: TraderId,
2881 strategy_id: StrategyId,
2882 instrument_id: InstrumentId,
2883 client_order_id: Option<ClientOrderId>,
2884 venue_order_id: Option<VenueOrderId>,
2885 ) -> Result<(), OKXWsError> {
2886 let mut builder = WsCancelOrderParamsBuilder::default();
2887
2888 let inst_id_code = self
2889 .get_inst_id_code(&instrument_id.symbol.inner())
2890 .ok_or_else(|| {
2891 OKXWsError::ClientError(format!(
2892 "No instIdCode cached for {instrument_id}, cannot cancel order"
2893 ))
2894 })?;
2895 builder.inst_id_code(inst_id_code);
2896
2897 if let Some(venue_order_id) = venue_order_id {
2898 builder.ord_id(venue_order_id.as_str());
2899 }
2900
2901 let cl_ord_key = client_order_id.map(|id| id.to_string());
2902
2903 if let Some(client_order_id) = client_order_id {
2904 builder.cl_ord_id(client_order_id.as_str());
2905 self.pending_cancels.insert(
2906 client_order_id.to_string(),
2907 PendingOrderInfo {
2908 trader_id,
2909 strategy_id,
2910 instrument_id,
2911 },
2912 );
2913 }
2914
2915 let params = builder
2916 .build()
2917 .map_err(|e| OKXWsError::ClientError(format!("Build cancel params error: {e}")))?;
2918
2919 let request_id = self.generate_unique_request_id();
2920 let request = OKXWsRequest {
2921 id: Some(request_id.clone()),
2922 op: super::enums::OKXWsOperation::CancelOrder,
2923 exp_time: None,
2924 args: vec![params],
2925 };
2926
2927 let payload = serde_json::to_string(&request)
2928 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel: {e}")))?;
2929
2930 let cmd = HandlerCommand::Send {
2931 payload,
2932 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_CANCEL.to_vec()),
2933 request_id: Some(request_id),
2934 client_order_ids: client_order_id.into_iter().collect(),
2935 op: Some(super::enums::OKXWsOperation::CancelOrder),
2936 };
2937
2938 let result = self.send_cmd(cmd).await;
2939
2940 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2941 self.pending_cancels.remove(key);
2942 }
2943
2944 result
2945 }
2946
2947 pub async fn mass_cancel_orders(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
2957 let (inst_type, inst_family) = {
2958 let instrument = self
2959 .instruments_cache
2960 .get_cloned(&instrument_id.symbol.inner())
2961 .ok_or_else(|| {
2962 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2963 })?;
2964
2965 let inst_type = okx_instrument_type(&instrument)
2966 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2967
2968 let symbol = instrument.symbol().inner();
2969 let inst_family = match &instrument {
2970 InstrumentAny::CurrencyPair(_) => symbol.as_str().to_string(),
2971 InstrumentAny::CryptoPerpetual(_) => symbol
2972 .as_str()
2973 .strip_suffix("-SWAP")
2974 .unwrap_or(symbol.as_str())
2975 .to_string(),
2976 InstrumentAny::CryptoFuture(_) => {
2977 let s = symbol.as_str();
2978 if let Some(idx) = s.rfind('-') {
2979 s[..idx].to_string()
2980 } else {
2981 s.to_string()
2982 }
2983 }
2984 _ => {
2985 return Err(OKXWsError::ClientError(
2986 "Unsupported instrument type for mass cancel".to_string(),
2987 ));
2988 }
2989 };
2990
2991 (inst_type, inst_family)
2992 };
2993
2994 let params = WsMassCancelParams {
2995 inst_type,
2996 inst_family: Ustr::from(&inst_family),
2997 };
2998
2999 let request_id = self.generate_unique_request_id();
3000 let request = OKXWsRequest {
3001 id: Some(request_id.clone()),
3002 op: super::enums::OKXWsOperation::MassCancel,
3003 exp_time: None,
3004 args: vec![
3005 serde_json::to_value(params).map_err(|e| OKXWsError::JsonError(e.to_string()))?,
3006 ],
3007 };
3008
3009 let payload = serde_json::to_string(&request)
3010 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize mass cancel: {e}")))?;
3011
3012 let cmd = HandlerCommand::Send {
3013 payload,
3014 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_MASS_CANCEL.to_vec()),
3015 request_id: Some(request_id),
3016 client_order_ids: Vec::new(),
3017 op: Some(super::enums::OKXWsOperation::MassCancel),
3018 };
3019
3020 self.send_cmd(cmd).await
3021 }
3022
3023 #[expect(clippy::type_complexity)]
3030 pub async fn batch_submit_orders(
3031 &self,
3032 orders: Vec<(
3033 OKXInstrumentType,
3034 InstrumentId,
3035 OKXTradeMode,
3036 ClientOrderId,
3037 OrderSide,
3038 Option<PositionSide>,
3039 OrderType,
3040 Quantity,
3041 Option<Price>,
3042 Option<Price>,
3043 Option<bool>,
3044 Option<bool>,
3045 Option<String>,
3046 Option<bool>,
3047 Option<bool>,
3048 Option<bool>,
3049 )>,
3050 ) -> Result<(), OKXWsError> {
3051 let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.3).collect();
3052 let args: Vec<Value> = {
3053 let mut args = Vec::with_capacity(orders.len());
3054 let inst_id_codes = self.inst_id_code_cache.load();
3055 let instruments = self.instruments_cache.load();
3056
3057 for (
3058 inst_type,
3059 inst_id,
3060 td_mode,
3061 cl_ord_id,
3062 ord_side,
3063 pos_side,
3064 ord_type,
3065 qty,
3066 pr,
3067 tp,
3068 post_only,
3069 reduce_only,
3070 outcome,
3071 rpi,
3072 rpi_taker_access,
3073 rpi_px_round,
3074 ) in orders
3075 {
3076 let rpi = rpi.unwrap_or(false);
3077 let mut builder = WsPostOrderParamsBuilder::default();
3078
3079 let (inst_id_symbol, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3080 &inst_id_codes,
3081 &inst_id,
3082 "submit",
3083 )?;
3084 builder.inst_id_code(inst_id_code);
3085
3086 builder.td_mode(td_mode);
3087 builder.cl_ord_id(cl_ord_id.as_str());
3088 builder.side(ord_side);
3089
3090 if inst_type != OKXInstrumentType::Events
3091 && let Some(instrument) = instruments.get(&inst_id_symbol)
3092 {
3093 builder.ccy(instrument.quote_currency().to_string());
3094 }
3095
3096 if let Some(ps) = pos_side {
3097 builder.pos_side(OKXPositionSide::from(ps));
3098 } else if matches!(
3099 inst_type,
3100 OKXInstrumentType::Swap
3101 | OKXInstrumentType::Futures
3102 | OKXInstrumentType::Option
3103 ) {
3104 builder.pos_side(OKXPositionSide::Net);
3105 }
3106
3107 if rpi && ord_type != OrderType::Limit {
3108 return Err(OKXWsError::ClientError(
3109 "OKX RPI batch orders require limit orders".to_string(),
3110 ));
3111 }
3112
3113 let okx_ord_type = if rpi {
3114 OKXOrderType::Rpi
3115 } else if post_only.unwrap_or(false) {
3116 OKXOrderType::PostOnly
3117 } else {
3118 match ord_type {
3119 OrderType::Market => OKXOrderType::Market,
3120 OrderType::Limit => OKXOrderType::Limit,
3121 OrderType::MarketToLimit => OKXOrderType::Ioc,
3122 _ => {
3123 return Err(OKXWsError::ClientError(format!(
3124 "Unsupported order type for batch submit: {ord_type:?}"
3125 )));
3126 }
3127 }
3128 };
3129
3130 builder.ord_type(okx_ord_type);
3131 builder.sz(qty.to_string());
3132
3133 if let Some(p) = pr {
3134 builder.px(p.to_string());
3135 } else if let Some(p) = tp {
3136 builder.px(p.to_string());
3137 }
3138
3139 if let Some(reduce_only) =
3140 okx_reduce_only_wire_value(inst_type, td_mode, ord_side, pos_side, reduce_only)
3141 .map_err(OKXWsError::ClientError)?
3142 {
3143 builder.reduce_only(reduce_only);
3144 }
3145
3146 if inst_type == OKXInstrumentType::Events && outcome.is_none() {
3147 return Err(OKXWsError::ClientError(
3148 "OKX event contract orders require `outcome`".to_string(),
3149 ));
3150 }
3151
3152 if let Some(outcome) = outcome {
3153 builder.outcome(outcome);
3154 }
3155
3156 if let Some(rpi_taker_access) = rpi_taker_access {
3157 builder.rpi_taker_access(rpi_taker_access);
3158 }
3159
3160 if let Some(rpi_px_round) = rpi_px_round {
3161 builder.rpi_px_round(rpi_px_round);
3162 }
3163
3164 if let Some(trade_quote_ccy) =
3165 self.resolve_spot_trade_quote_ccy(inst_type, inst_id_symbol)?
3166 {
3167 builder.trade_quote_ccy(trade_quote_ccy);
3168 }
3169
3170 builder.tag(OKX_NAUTILUS_BROKER_ID);
3171
3172 let params = builder.build().map_err(|e| {
3173 OKXWsError::ClientError(format!("Build order params error: {e}"))
3174 })?;
3175 let val = serde_json::to_value(params)
3176 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3177 args.push(val);
3178 }
3179 args
3180 };
3181
3182 self.ws_batch_place_orders(args, client_order_ids).await
3183 }
3184
3185 #[expect(clippy::type_complexity)]
3192 pub async fn batch_modify_orders(
3193 &self,
3194 orders: Vec<(
3195 OKXInstrumentType,
3196 InstrumentId,
3197 ClientOrderId,
3198 Option<String>,
3199 Option<Price>,
3200 Option<Quantity>,
3201 Option<bool>,
3202 Option<bool>,
3203 )>,
3204 ) -> Result<(), OKXWsError> {
3205 let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.2).collect();
3206 let args: Vec<Value> = {
3207 let mut args = Vec::with_capacity(orders.len());
3208 let inst_id_codes = self.inst_id_code_cache.load();
3209
3210 for (
3211 _inst_type,
3212 inst_id,
3213 cl_ord_id,
3214 request_id,
3215 pr,
3216 sz,
3217 rpi_taker_access,
3218 rpi_px_round,
3219 ) in orders
3220 {
3221 let mut builder = WsAmendOrderParamsBuilder::default();
3222
3223 let (_, inst_id_code) =
3224 Self::inst_id_symbol_and_code_from_snapshot(&inst_id_codes, &inst_id, "amend")?;
3225 builder.inst_id_code(inst_id_code);
3226
3227 builder.cl_ord_id(cl_ord_id.as_str());
3228
3229 if let Some(request_id) = request_id {
3230 builder.req_id(request_id);
3231 }
3232
3233 if let Some(p) = pr {
3234 builder.new_px(p.to_string());
3235 }
3236
3237 if let Some(q) = sz {
3238 builder.new_sz(q.to_string());
3239 }
3240
3241 if let Some(rpi_taker_access) = rpi_taker_access {
3242 builder.rpi_taker_access(rpi_taker_access);
3243 }
3244
3245 if let Some(rpi_px_round) = rpi_px_round {
3246 builder.rpi_px_round(rpi_px_round);
3247 }
3248
3249 let params = builder.build().map_err(|e| {
3250 OKXWsError::ClientError(format!("Build amend batch params error: {e}"))
3251 })?;
3252 let val = serde_json::to_value(params)
3253 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3254 args.push(val);
3255 }
3256 args
3257 };
3258
3259 self.ws_batch_amend_orders(args, client_order_ids).await
3260 }
3261
3262 pub async fn batch_cancel_orders(
3275 &self,
3276 orders: Vec<(InstrumentId, Option<ClientOrderId>, Option<VenueOrderId>)>,
3277 ) -> Result<(), OKXWsError> {
3278 let client_order_ids: Vec<ClientOrderId> = orders
3279 .iter()
3280 .filter_map(|(_, cl_ord_id, _)| *cl_ord_id)
3281 .collect();
3282 let args: Vec<Value> = {
3283 let mut args = Vec::with_capacity(orders.len());
3284 let inst_id_codes = self.inst_id_code_cache.load();
3285
3286 for (inst_id, cl_ord_id, ord_id) in orders {
3287 let mut builder = WsCancelOrderParamsBuilder::default();
3288
3289 let (_, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3290 &inst_id_codes,
3291 &inst_id,
3292 "cancel",
3293 )?;
3294 builder.inst_id_code(inst_id_code);
3295
3296 if let Some(c) = cl_ord_id {
3297 builder.cl_ord_id(c.as_str());
3298 }
3299
3300 if let Some(o) = ord_id {
3301 builder.ord_id(o.as_str());
3302 }
3303
3304 let params = builder.build().map_err(|e| {
3305 OKXWsError::ClientError(format!("Build cancel batch params error: {e}"))
3306 })?;
3307 let val = serde_json::to_value(params)
3308 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3309 args.push(val);
3310 }
3311 args
3312 };
3313
3314 self.ws_batch_cancel_orders(args, client_order_ids).await
3315 }
3316
3317 #[expect(clippy::too_many_arguments)]
3323 #[expect(
3324 clippy::unused_async,
3325 clippy::unused_async_trait_impl,
3326 reason = "Preserves the published async API while rejecting an unsupported operation"
3327 )]
3328 pub async fn submit_algo_order(
3329 &self,
3330 _trader_id: TraderId,
3331 _strategy_id: StrategyId,
3332 _instrument_id: InstrumentId,
3333 _td_mode: OKXTradeMode,
3334 _client_order_id: ClientOrderId,
3335 _order_side: OrderSide,
3336 _order_type: OrderType,
3337 _quantity: Quantity,
3338 _trigger_price: Option<Price>,
3339 _trigger_type: Option<TriggerType>,
3340 _limit_price: Option<Price>,
3341 _reduce_only: Option<bool>,
3342 _callback_ratio: Option<String>,
3343 _callback_spread: Option<String>,
3344 _activation_price: Option<Price>,
3345 ) -> Result<(), OKXWsError> {
3346 Err(OKXWsError::ClientError(
3347 "OKX algo placement requires HTTP; use OKXHttpClient::place_algo_order_with_domain_types"
3348 .to_string(),
3349 ))
3350 }
3351
3352 pub async fn cancel_algo_order(
3363 &self,
3364 _trader_id: TraderId,
3365 _strategy_id: StrategyId,
3366 instrument_id: InstrumentId,
3367 client_order_id: Option<ClientOrderId>,
3368 algo_order_id: Option<String>,
3369 ) -> Result<(), OKXWsError> {
3370 let mut builder = super::messages::WsCancelAlgoOrderParamsBuilder::default();
3371
3372 let inst_id_code = self
3373 .get_inst_id_code(&instrument_id.symbol.inner())
3374 .ok_or_else(|| {
3375 OKXWsError::ClientError(format!(
3376 "No instIdCode cached for {instrument_id}, cannot cancel algo order"
3377 ))
3378 })?;
3379 builder.inst_id_code(inst_id_code);
3380
3381 if let Some(algo_id) = algo_order_id {
3382 builder.algo_id(algo_id);
3383 }
3384
3385 if let Some(cl_ord_id) = client_order_id {
3386 builder.algo_cl_ord_id(cl_ord_id.to_string());
3387 }
3388
3389 let params = builder
3390 .build()
3391 .map_err(|e| OKXWsError::ClientError(format!("Build cancel algo params error: {e}")))?;
3392
3393 let request_id = self.generate_unique_request_id();
3394 let request = OKXWsRequest {
3395 id: Some(request_id.clone()),
3396 op: super::enums::OKXWsOperation::CancelAlgos,
3397 exp_time: None,
3398 args: vec![params],
3399 };
3400
3401 let payload = serde_json::to_string(&request)
3402 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel algo: {e}")))?;
3403
3404 let cmd = HandlerCommand::Send {
3405 payload,
3406 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_CANCEL.to_vec()),
3407 request_id: Some(request_id),
3408 client_order_ids: client_order_id.into_iter().collect(),
3409 op: Some(super::enums::OKXWsOperation::CancelAlgos),
3410 };
3411
3412 self.send_cmd(cmd).await
3413 }
3414
3415 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), OKXWsError> {
3417 self.cmd_tx
3418 .read()
3419 .await
3420 .send(cmd)
3421 .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))
3422 }
3423}
3424
3425fn authentication_timestamp(now: UnixNanos) -> String {
3426 now.as_seconds().to_string()
3427}
3428
3429fn subscription_args(
3430 subscriptions_inst_type: &DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>,
3431 subscriptions_inst_family: &DashMap<OKXWsChannel, AHashSet<Ustr>>,
3432 subscriptions_inst_id: &DashMap<OKXWsChannel, AHashSet<Ustr>>,
3433 subscriptions_bare: &DashMap<OKXWsChannel, bool>,
3434) -> Vec<OKXSubscriptionArg> {
3435 let mut args = Vec::new();
3436
3437 for entry in subscriptions_inst_type {
3438 let (channel, inst_types) = entry.pair();
3439 for inst_type in inst_types {
3440 args.push(OKXSubscriptionArg {
3441 channel: channel.clone(),
3442 inst_type: Some(*inst_type),
3443 inst_family: None,
3444 inst_id: None,
3445 });
3446 }
3447 }
3448
3449 for entry in subscriptions_inst_family {
3450 let (channel, inst_families) = entry.pair();
3451 for inst_family in inst_families {
3452 args.push(OKXSubscriptionArg {
3453 channel: channel.clone(),
3454 inst_type: None,
3455 inst_family: Some(*inst_family),
3456 inst_id: None,
3457 });
3458 }
3459 }
3460
3461 for entry in subscriptions_inst_id {
3462 let (channel, inst_ids) = entry.pair();
3463 for inst_id in inst_ids {
3464 args.push(OKXSubscriptionArg {
3465 channel: channel.clone(),
3466 inst_type: None,
3467 inst_family: None,
3468 inst_id: Some(*inst_id),
3469 });
3470 }
3471 }
3472
3473 for entry in subscriptions_bare {
3474 args.push(OKXSubscriptionArg {
3475 channel: entry.key().clone(),
3476 inst_type: None,
3477 inst_family: None,
3478 inst_id: None,
3479 });
3480 }
3481
3482 args.sort_unstable_by_key(topic_from_subscription_arg);
3483 args
3484}
3485
3486fn subscription_arg_batches(
3487 args: &[OKXSubscriptionArg],
3488) -> impl Iterator<Item = &[OKXSubscriptionArg]> {
3489 args.chunks(OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE)
3490}
3491
3492fn auth_attempt_superseded(error: &Error) -> bool {
3493 error.to_string().contains("superseded")
3494}
3495
3496fn reconnect_auth_retry_delay(attempt: u32) -> Duration {
3497 let shift = attempt.saturating_sub(1).min(31);
3498 let factor = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
3499
3500 RECONNECT_AUTH_RETRY_INITIAL
3501 .saturating_mul(factor)
3502 .min(RECONNECT_AUTH_RETRY_MAX)
3503}
3504
3505async fn authenticate_session(
3506 credential: &Credential,
3507 auth_tracker: &AuthTracker,
3508 cmd_tx: &tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
3509 clock: &'static AtomicTime,
3510 auth_timeout_secs: u64,
3511) -> Result<(), Error> {
3512 let rx = auth_tracker.begin();
3513 let timestamp = authentication_timestamp(clock.get_time_ns());
3514 let signature = credential.sign(×tamp, "GET", "/users/self/verify", "");
3515
3516 let auth_message = OKXAuthentication {
3517 op: "login",
3518 args: vec![OKXAuthenticationArg {
3519 api_key: SecretString::from(credential.api_key()),
3520 passphrase: SecretString::from(credential.api_passphrase()),
3521 timestamp,
3522 sign: SecretString::from(signature),
3523 }],
3524 };
3525
3526 let payload = serde_json::to_string(&auth_message)
3527 .map(SecretString::from)
3528 .map_err(|e| {
3529 Error::Io(std::io::Error::other(format!(
3530 "Failed to serialize auth message: {e}"
3531 )))
3532 })?;
3533
3534 cmd_tx
3535 .read()
3536 .await
3537 .send(HandlerCommand::Authenticate { payload })
3538 .map_err(|e| {
3539 Error::Io(std::io::Error::other(format!(
3540 "Failed to send authenticate command: {e}"
3541 )))
3542 })?;
3543
3544 match auth_tracker
3545 .wait_for_result::<OKXWsError>(Duration::from_secs(auth_timeout_secs), rx)
3546 .await
3547 {
3548 Ok(()) => {
3549 log::debug!("WebSocket authenticated");
3550 Ok(())
3551 }
3552 Err(e) => {
3553 let auth_error = Error::Io(std::io::Error::other(e.to_string()));
3554 if !auth_attempt_superseded(&auth_error) {
3555 log::warn!("WebSocket authentication failed: error={e}");
3556 }
3557
3558 Err(auth_error)
3559 }
3560 }
3561}
3562
3563#[allow(
3564 clippy::too_many_arguments,
3565 reason = "retry loop needs the session pieces without cloning the websocket client"
3566)]
3567async fn retry_reconnect_authentication(
3568 credential: Credential,
3569 auth_tracker: AuthTracker,
3570 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
3571 clock: &'static AtomicTime,
3572 auth_timeout_secs: u64,
3573 signal: Arc<AtomicBool>,
3574 abort: CancellationToken,
3575 generation_flag: Arc<AtomicU64>,
3576 generation: u64,
3577) {
3578 let mut attempt = 0u32;
3579
3580 loop {
3581 if signal.load(Ordering::Acquire) || abort.is_cancelled() {
3582 break;
3583 }
3584
3585 if generation_flag.load(Ordering::Acquire) != generation {
3586 break;
3587 }
3588
3589 if cmd_tx.read().await.is_closed() {
3590 break;
3591 }
3592
3593 if auth_tracker.is_authenticated() {
3594 break;
3595 }
3596
3597 attempt = attempt.saturating_add(1);
3598
3599 match authenticate_session(
3600 &credential,
3601 &auth_tracker,
3602 &cmd_tx,
3603 clock,
3604 auth_timeout_secs,
3605 )
3606 .await
3607 {
3608 Ok(()) => {
3609 log::debug!("Re-authenticated after reconnection");
3610 break;
3611 }
3612 Err(e) => {
3613 if generation_flag.load(Ordering::Acquire) != generation {
3614 break;
3615 }
3616
3617 if auth_tracker.is_authenticated() {
3618 break;
3619 }
3620
3621 let delay = reconnect_auth_retry_delay(attempt);
3622
3623 log::warn!(
3624 "Re-authentication after reconnection failed, retrying in {delay:?}: {e}"
3625 );
3626
3627 tokio::select! {
3628 biased;
3629 () = abort.cancelled() => break,
3630 () = time::sleep(delay) => {}
3631 }
3632 }
3633 }
3634 }
3635}
3636
3637pub(crate) fn ws_channel_for_book(channel: OKXBookChannel) -> OKXWsChannel {
3638 match channel {
3639 OKXBookChannel::Book => OKXWsChannel::Books,
3640 OKXBookChannel::BookL2Tbt => OKXWsChannel::BooksTbt,
3641 OKXBookChannel::Books50L2Tbt => OKXWsChannel::Books50Tbt,
3642 OKXBookChannel::BooksRpi => OKXWsChannel::BooksRpi,
3643 OKXBookChannel::SprdBooks5 => OKXWsChannel::SprdBooks5,
3644 }
3645}
3646
3647fn log_receiver_dropped(signal: &AtomicBool, item: &str) {
3648 if signal.load(Ordering::Acquire) {
3649 log::debug!("Receiver dropped after stop signal while forwarding {item}");
3650 } else {
3651 log::error!("Failed to send {item} through channel: receiver dropped");
3652 }
3653}
3654
3655#[cfg(test)]
3656mod tests {
3657 use futures_util::StreamExt;
3658 use nautilus_core::time::get_atomic_clock_realtime;
3659 use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
3660 use nautilus_model::{identifiers::ClientId, instruments::stubs::crypto_perpetual_ethusdt};
3661 use nautilus_network::RECONNECTED;
3662 use rstest::rstest;
3663 use tokio_tungstenite::tungstenite::Message;
3664
3665 use super::*;
3666 use crate::{
3667 common::{
3668 consts::{OKX_POST_ONLY_CANCEL_SOURCE, OKX_VENUE},
3669 enums::{
3670 OKXExecType, OKXOrderCategory, OKXOrderStatus, OKXPriceType, OKXQuickMarginType,
3671 OKXSelfTradePreventionMode, OKXSide,
3672 },
3673 },
3674 websocket::{
3675 handler::is_post_only_auto_cancel,
3676 messages::{OKXOrderMsg, OKXWebSocketError, OKXWsFrame},
3677 },
3678 };
3679
3680 struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
3681
3682 impl Drop for DropSignal {
3683 fn drop(&mut self) {
3684 if let Some(sender) = self.0.take() {
3685 let _ = sender.send(());
3686 }
3687 }
3688 }
3689
3690 struct BlockingDrop(Arc<(parking_lot::Mutex<bool>, parking_lot::Condvar)>);
3691
3692 impl Drop for BlockingDrop {
3693 fn drop(&mut self) {
3694 let (lock, condvar) = &*self.0;
3695 let mut released = lock.lock();
3696 condvar.wait_while(&mut released, |released| !*released);
3697 }
3698 }
3699
3700 #[rstest]
3701 #[case(OKXBookChannel::Book, OKXWsChannel::Books)]
3702 #[case(OKXBookChannel::BookL2Tbt, OKXWsChannel::BooksTbt)]
3703 #[case(OKXBookChannel::Books50L2Tbt, OKXWsChannel::Books50Tbt)]
3704 #[case(OKXBookChannel::BooksRpi, OKXWsChannel::BooksRpi)]
3705 #[case(OKXBookChannel::SprdBooks5, OKXWsChannel::SprdBooks5)]
3706 fn test_ws_channel_for_book(#[case] channel: OKXBookChannel, #[case] expected: OKXWsChannel) {
3707 assert_eq!(ws_channel_for_book(channel), expected);
3708 }
3709
3710 #[rstest]
3711 fn test_timestamp_format_for_websocket_auth() {
3712 let now = UnixNanos::new(1_700_000_000_999_999_999);
3713
3714 assert_eq!(authentication_timestamp(now), "1700000000");
3715 }
3716
3717 #[rstest]
3718 fn test_subscription_args_are_sorted_by_topic() {
3719 let client = OKXWebSocketClient::default();
3720 client
3721 .subscriptions_inst_type
3722 .entry(OKXWsChannel::Instruments)
3723 .or_default()
3724 .extend([OKXInstrumentType::Swap, OKXInstrumentType::Spot]);
3725 client
3726 .subscriptions_inst_family
3727 .entry(OKXWsChannel::OpenInterest)
3728 .or_default()
3729 .insert(Ustr::from("BTC-USD"));
3730 client
3731 .subscriptions_inst_id
3732 .entry(OKXWsChannel::Tickers)
3733 .or_default()
3734 .extend([Ustr::from("ETH-USDT"), Ustr::from("BTC-USDT")]);
3735 client.subscriptions_bare.insert(OKXWsChannel::Status, true);
3736
3737 let topics = subscription_args(
3738 &client.subscriptions_inst_type,
3739 &client.subscriptions_inst_family,
3740 &client.subscriptions_inst_id,
3741 &client.subscriptions_bare,
3742 )
3743 .iter()
3744 .map(topic_from_subscription_arg)
3745 .collect::<Vec<_>>();
3746
3747 assert_eq!(
3748 topics,
3749 [
3750 "Instruments:Spot",
3751 "Instruments:Swap",
3752 "OpenInterest:BTC-USD",
3753 "Status",
3754 "Tickers:BTC-USDT",
3755 "Tickers:ETH-USDT",
3756 ]
3757 );
3758 }
3759
3760 #[rstest]
3761 fn test_new_without_credentials() {
3762 let client = OKXWebSocketClient::default();
3763 assert!(client.credential.is_none());
3764 assert_eq!(client.api_key(), None);
3765 }
3766
3767 #[rstest]
3768 fn test_instruments_cache_arc_observes_post_clone_writes() {
3769 let client = OKXWebSocketClient::default();
3770 let cache = client.instruments_cache_arc();
3771 assert!(cache.load().is_empty());
3772
3773 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3774 let symbol = instrument.symbol().inner();
3775 client.cache_instruments(std::slice::from_ref(&instrument));
3776
3777 let loaded = cache.load();
3778 assert_eq!(loaded.len(), 1);
3779 let stored = loaded.get(&symbol).expect("instrument not refreshed");
3780 assert_eq!(stored.id(), instrument.id());
3781 }
3782
3783 #[rstest]
3784 fn test_add_option_greeks_sub_defaults_to_both_conventions() {
3785 let client = OKXWebSocketClient::default();
3786 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3787
3788 client.add_option_greeks_sub(instrument_id);
3789
3790 let subs = client.option_greeks_subs().load();
3791 let stored = subs.get(&instrument_id).expect("instrument not registered");
3792 assert_eq!(stored.len(), 2);
3793 assert!(stored.contains(&OKXGreeksType::Bs));
3794 assert!(stored.contains(&OKXGreeksType::Pa));
3795 }
3796
3797 #[rstest]
3798 #[case::bs_only(vec![OKXGreeksType::Bs])]
3799 #[case::pa_only(vec![OKXGreeksType::Pa])]
3800 #[case::both(vec![OKXGreeksType::Bs, OKXGreeksType::Pa])]
3801 fn test_add_option_greeks_sub_with_conventions_stores_requested_set(
3802 #[case] conventions: Vec<OKXGreeksType>,
3803 ) {
3804 let client = OKXWebSocketClient::default();
3805 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3806 let set: AHashSet<OKXGreeksType> = conventions.iter().copied().collect();
3807
3808 client.add_option_greeks_sub_with_conventions(instrument_id, set.clone());
3809
3810 let subs = client.option_greeks_subs().load();
3811 let stored = subs.get(&instrument_id).expect("instrument not registered");
3812 assert_eq!(stored, &set);
3813 }
3814
3815 #[rstest]
3816 fn test_add_option_greeks_sub_with_empty_conventions_falls_back_to_both() {
3817 let client = OKXWebSocketClient::default();
3818 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3819
3820 client.add_option_greeks_sub_with_conventions(instrument_id, AHashSet::new());
3821
3822 let subs = client.option_greeks_subs().load();
3823 let stored = subs.get(&instrument_id).expect("instrument not registered");
3824 assert_eq!(stored.len(), 2);
3825 }
3826
3827 #[rstest]
3828 fn test_remove_option_greeks_sub_clears_entry() {
3829 let client = OKXWebSocketClient::default();
3830 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3831
3832 client.add_option_greeks_sub(instrument_id);
3833 client.remove_option_greeks_sub(&instrument_id);
3834
3835 let subs = client.option_greeks_subs().load();
3836 assert!(!subs.contains_key(&instrument_id));
3837 }
3838
3839 #[rstest]
3840 fn test_new_with_credentials() {
3841 let client = OKXWebSocketClient::new(
3842 None,
3843 Some("test_key".to_string()),
3844 Some("test_secret".to_string()),
3845 Some("test_passphrase".to_string()),
3846 None,
3847 None,
3848 None,
3849 TransportBackend::default(),
3850 None,
3851 )
3852 .unwrap();
3853 assert!(client.credential.is_some());
3854 assert_eq!(client.api_key(), Some("test_key"));
3855 }
3856
3857 #[rstest]
3858 fn test_new_partial_credentials_fails() {
3859 let result = OKXWebSocketClient::new(
3860 None,
3861 Some("test_key".to_string()),
3862 None,
3863 Some("test_passphrase".to_string()),
3864 None,
3865 None,
3866 None,
3867 TransportBackend::default(),
3868 None,
3869 );
3870 result.unwrap_err();
3871 }
3872
3873 #[rstest]
3874 fn test_request_id_generation() {
3875 let client = OKXWebSocketClient::default();
3876
3877 let initial_counter = client.request_id_counter.load(Ordering::SeqCst);
3878
3879 let id1 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3880 let id2 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3881
3882 assert_eq!(id1, initial_counter);
3883 assert_eq!(id2, initial_counter + 1);
3884 assert_eq!(
3885 client.request_id_counter.load(Ordering::SeqCst),
3886 initial_counter + 2
3887 );
3888 }
3889
3890 #[tokio::test]
3891 async fn subscribe_during_connect_reaches_initialized_handler() {
3892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3893 let addr = listener.local_addr().unwrap();
3894 let (received_tx, received_rx) = tokio::sync::oneshot::channel();
3895
3896 let server = tokio::spawn(async move {
3897 let (socket, _) = listener.accept().await.unwrap();
3898 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
3899 if let Some(Ok(message)) = socket.next().await {
3900 let _ = received_tx.send(message);
3901
3902 while matches!(socket.next().await, Some(Ok(_))) {}
3903 }
3904 });
3905
3906 let mut ws_client = OKXWebSocketClient::new(
3907 Some(format!("ws://{addr}")),
3908 None,
3909 None,
3910 None,
3911 None,
3912 None,
3913 None,
3914 TransportBackend::default(),
3915 None,
3916 )
3917 .unwrap();
3918 let observer = ws_client.clone();
3919 let sender = Arc::clone(&ws_client.cmd_tx);
3920 let publication = sender.read().await;
3921 let mut connect = Box::pin(ws_client.connect());
3922
3923 tokio::time::timeout(Duration::from_secs(5), async {
3924 loop {
3925 assert!(futures_util::poll!(connect.as_mut()).is_pending());
3926
3927 if sender.try_read().is_err() {
3928 break;
3929 }
3930
3931 tokio::task::yield_now().await;
3932 }
3933 })
3934 .await
3935 .expect("connect must wait to publish its sender");
3936
3937 let active_before_publication = observer.is_active();
3938 let mut subscribe =
3939 Box::pin(observer.subscribe_inst_id(OKXWsChannel::Trades, Ustr::from("BTC-USDT")));
3940 assert!(futures_util::poll!(subscribe.as_mut()).is_pending());
3941
3942 let mut barrier = Box::pin(sender.write());
3944 assert!(futures_util::poll!(barrier.as_mut()).is_pending());
3945 drop(publication);
3946 let connect_result = futures_util::poll!(connect.as_mut());
3947 subscribe.await.expect("concurrent subscribe command");
3948 let barrier = barrier.await;
3949 let received = tokio::time::timeout(Duration::from_secs(2), received_rx).await;
3950 drop(barrier);
3951
3952 if let std::task::Poll::Ready(result) = connect_result {
3953 result.expect("connect failed");
3954 } else {
3955 connect.as_mut().await.expect("connect failed");
3956 }
3957
3958 drop(connect);
3959 ws_client.close().await.expect("close failed");
3960 server.await.expect("server task failed");
3961
3962 assert!(
3963 !active_before_publication,
3964 "active before sender publication"
3965 );
3966 let message = received.expect("subscription must reach server").unwrap();
3967
3968 let Message::Text(text) = message else {
3969 panic!("expected text subscription")
3970 };
3971
3972 let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
3973 assert_eq!(
3974 payload,
3975 serde_json::json!({
3976 "op": "subscribe",
3977 "args": [{ "channel": "trades", "instId": "BTC-USDT" }],
3978 })
3979 );
3980 }
3981
3982 #[rstest]
3983 fn test_client_state_management() {
3984 let client = OKXWebSocketClient::default();
3985
3986 assert!(client.is_closed());
3987 assert!(!client.is_active());
3988
3989 let client_with_heartbeat = OKXWebSocketClient::new(
3990 None,
3991 None,
3992 None,
3993 None,
3994 None,
3995 Some(30),
3996 None,
3997 TransportBackend::default(),
3998 None,
3999 )
4000 .unwrap();
4001
4002 assert!(client_with_heartbeat.heartbeat.is_some());
4003 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
4004 }
4005
4006 #[rstest]
4007 #[tokio::test]
4008 async fn begin_shutdown_stops_handler_before_bounded_close() {
4009 let client_id = ClientId::from("OKX-TEST");
4010 let endpoint = Ustr::from("okx-test-stream");
4011 let registry = SocketReconnectRegistry::default();
4012 let control =
4013 SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, ®istry);
4014 let _sink = control.sink();
4015 control.register(|| SocketReconnectRequestOutcome::Accepted);
4016 let mut client = OKXWebSocketClient::default().with_socket_control(control);
4017 client
4018 .connection_mode
4019 .load()
4020 .store(ConnectionMode::Active.as_u8(), Ordering::SeqCst);
4021 let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
4022 let signal = DropSignal(Some(drop_tx));
4023 let handler_abort = CancellationToken::new();
4024 *client.handler_abort.lock() = handler_abort.clone();
4025 client
4026 .handler_tasks
4027 .spawn(async move {
4028 let _signal = signal;
4029 handler_abort.cancelled().await;
4030 })
4031 .expect("handler task should register");
4032
4033 assert!(registry.handle(client_id, endpoint).is_some());
4034 client.begin_shutdown();
4035
4036 tokio::time::timeout(Duration::from_secs(1), drop_rx)
4037 .await
4038 .expect("begin shutdown must drop the handler task")
4039 .expect("drop signal");
4040 assert!(client.is_closed());
4041 assert!(!client.handler_tasks.is_open());
4042 assert!(registry.handle(client_id, endpoint).is_some());
4043
4044 client.close().await.expect("bounded close");
4045 assert!(!client.has_task());
4046 assert!(registry.handle(client_id, endpoint).is_none());
4047 }
4048
4049 #[rstest]
4050 #[tokio::test]
4051 async fn connect_rollback_closes_handler_admission_and_deregisters_socket() {
4052 let client_id = ClientId::from("OKX-CONNECT-ROLLBACK");
4053 let endpoint = Ustr::from("okx-connect-rollback");
4054 let registry = SocketReconnectRegistry::default();
4055 let control =
4056 SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, ®istry);
4057 control.register(|| SocketReconnectRequestOutcome::Accepted);
4058 let handler_tasks = Arc::new(TaskGroup::new());
4059 let signal = Arc::new(AtomicBool::new(false));
4060 let handler_abort = CancellationToken::new();
4061
4062 let rollback = ConnectRollback {
4063 handler_tasks: Arc::clone(&handler_tasks),
4064 signal: Arc::clone(&signal),
4065 handler_abort: handler_abort.clone(),
4066 socket_control: Some(Arc::new(control)),
4067 armed: true,
4068 };
4069
4070 drop(rollback);
4071
4072 assert!(!handler_tasks.is_open());
4073 assert!(signal.load(Ordering::Acquire));
4074 assert!(handler_abort.is_cancelled());
4075 assert!(registry.handle(client_id, endpoint).is_none());
4076 handler_tasks
4077 .finish_shutdown(Duration::ZERO, Duration::from_secs(1))
4078 .await
4079 .expect("empty handler scope should drain");
4080 }
4081
4082 #[rstest]
4083 #[tokio::test]
4084 async fn request_close_signals_before_handler_shutdown() {
4085 let mut client = OKXWebSocketClient::default();
4086 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4087 client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
4088 client.signal.store(false, Ordering::Release);
4089
4090 client.request_close().await;
4091
4092 assert!(client.signal.load(Ordering::Acquire));
4093 assert!(matches!(cmd_rx.try_recv(), Ok(HandlerCommand::Disconnect)));
4094 }
4095
4096 #[rstest]
4097 #[tokio::test]
4098 async fn close_joins_handler_shared_with_clone() {
4099 let mut client = OKXWebSocketClient::default();
4100 let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
4101 let signal = DropSignal(Some(drop_tx));
4102 client
4103 .handler_tasks
4104 .spawn(async move {
4105 let _signal = signal;
4106 std::future::pending::<()>().await;
4107 })
4108 .expect("handler task should register");
4109 let retained = client.clone();
4110
4111 client.close().await.expect("close with retained clone");
4112
4113 tokio::time::timeout(Duration::from_secs(1), drop_rx)
4114 .await
4115 .expect("close must drop the handler task")
4116 .expect("drop signal");
4117 assert!(!client.has_task());
4118 assert!(!retained.has_task());
4119 }
4120
4121 #[rstest]
4122 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4123 async fn timeout_retains_unfinished_handler_task() {
4124 let mut client = OKXWebSocketClient::default();
4125 let release = Arc::new((parking_lot::Mutex::new(false), parking_lot::Condvar::new()));
4126 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4127 let blocking_drop = BlockingDrop(Arc::clone(&release));
4128 client
4129 .handler_tasks
4130 .spawn(async move {
4131 let _blocking_drop = blocking_drop;
4132 started_tx.send(()).expect("started receiver");
4133 std::future::pending::<()>().await;
4134 })
4135 .expect("handler task should register");
4136 started_rx.await.expect("blocking task started");
4137 client.begin_shutdown();
4138
4139 let result = client.close_stream_task(Duration::from_millis(10)).await;
4140 let retained = client.has_task();
4141 let reconnect_result = client.connect().await;
4142
4143 let (lock, condvar) = &*release;
4144 *lock.lock() = true;
4145 condvar.notify_all();
4146
4147 client
4148 .close_stream_task(Duration::from_secs(1))
4149 .await
4150 .expect("blocking handler task terminated");
4151
4152 assert!(result.is_err());
4153 assert!(retained);
4154 assert_eq!(
4155 reconnect_result
4156 .expect_err("reconnect with unfinished handler")
4157 .to_string(),
4158 "Cannot connect while previous WebSocket handler task is still running"
4159 );
4160 assert!(!client.has_task());
4161 }
4162
4163 #[rstest]
4164 fn test_websocket_error_handling() {
4165 let clock = get_atomic_clock_realtime();
4166 let ts = clock.get_time_ns().as_u64();
4167
4168 let error = OKXWebSocketError {
4169 code: "60012".to_string(),
4170 message: "Invalid request".to_string(),
4171 conn_id: None,
4172 timestamp: ts,
4173 };
4174
4175 assert_eq!(error.code, "60012");
4176 assert_eq!(error.message, "Invalid request");
4177 assert_eq!(error.timestamp, ts);
4178
4179 let nautilus_msg = OKXWsMessage::Error(error);
4180 match nautilus_msg {
4181 OKXWsMessage::Error(e) => {
4182 assert_eq!(e.code, "60012");
4183 assert_eq!(e.message, "Invalid request");
4184 }
4185 _ => panic!("Expected Error variant"),
4186 }
4187 }
4188
4189 #[rstest]
4190 fn test_request_id_generation_sequence() {
4191 let client = OKXWebSocketClient::default();
4192
4193 let initial_counter = client
4194 .request_id_counter
4195 .load(std::sync::atomic::Ordering::SeqCst);
4196 let mut ids = Vec::new();
4197
4198 for _ in 0..10 {
4199 let id = client
4200 .request_id_counter
4201 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4202 ids.push(id);
4203 }
4204
4205 for (i, &id) in ids.iter().enumerate() {
4206 assert_eq!(id, initial_counter + i as u64);
4207 }
4208
4209 assert_eq!(
4210 client
4211 .request_id_counter
4212 .load(std::sync::atomic::Ordering::SeqCst),
4213 initial_counter + 10
4214 );
4215 }
4216
4217 #[rstest]
4218 fn test_client_state_transitions() {
4219 let client = OKXWebSocketClient::default();
4220
4221 assert!(client.is_closed());
4222 assert!(!client.is_active());
4223
4224 let client_with_heartbeat = OKXWebSocketClient::new(
4225 None,
4226 None,
4227 None,
4228 None,
4229 None,
4230 Some(30), None,
4232 TransportBackend::default(),
4233 None,
4234 )
4235 .unwrap();
4236
4237 assert!(client_with_heartbeat.heartbeat.is_some());
4238 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
4239 }
4240
4241 #[rstest]
4242 fn test_websocket_error_scenarios() {
4243 let clock = get_atomic_clock_realtime();
4244 let ts = clock.get_time_ns().as_u64();
4245
4246 let error_scenarios = vec![
4247 ("60012", "Invalid request", None),
4248 ("60009", "Invalid API key", Some("conn-123".to_string())),
4249 ("60014", "Too many requests", None),
4250 ("50001", "Order not found", None),
4251 ];
4252
4253 for (code, message, conn_id) in error_scenarios {
4254 let error = OKXWebSocketError {
4255 code: code.to_string(),
4256 message: message.to_string(),
4257 conn_id: conn_id.clone(),
4258 timestamp: ts,
4259 };
4260
4261 assert_eq!(error.code, code);
4262 assert_eq!(error.message, message);
4263 assert_eq!(error.conn_id, conn_id);
4264 assert_eq!(error.timestamp, ts);
4265
4266 let nautilus_msg = OKXWsMessage::Error(error);
4267 match nautilus_msg {
4268 OKXWsMessage::Error(e) => {
4269 assert_eq!(e.code, code);
4270 assert_eq!(e.message, message);
4271 assert_eq!(e.conn_id, conn_id);
4272 }
4273 _ => panic!("Expected Error variant"),
4274 }
4275 }
4276 }
4277
4278 #[rstest]
4279 fn test_feed_handler_reconnection_detection() {
4280 let msg = Message::Text(RECONNECTED.to_string().into());
4281 let result = OKXWsFeedHandler::parse_raw_message(msg);
4282 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4283 }
4284
4285 #[rstest]
4286 fn test_feed_handler_normal_message_processing() {
4287 let ping_msg = Message::Text(TEXT_PING.to_string().into());
4288 let result = OKXWsFeedHandler::parse_raw_message(ping_msg);
4289 assert!(matches!(result, Some(OKXWsFrame::Ping)));
4290
4291 let sub_msg = r#"{
4292 "event": "subscribe",
4293 "arg": {
4294 "channel": "tickers",
4295 "instType": "SPOT"
4296 },
4297 "connId": "a4d3ae55"
4298 }"#;
4299
4300 let sub_result =
4301 OKXWsFeedHandler::parse_raw_message(Message::Text(sub_msg.to_string().into()));
4302 assert!(matches!(sub_result, Some(OKXWsFrame::Subscription { .. })));
4303 }
4304
4305 #[rstest]
4306 fn test_feed_handler_close_message() {
4307 let result = OKXWsFeedHandler::parse_raw_message(Message::Close(None));
4308 assert!(result.is_none());
4309 }
4310
4311 #[rstest]
4312 fn test_reconnection_message_constant() {
4313 assert_eq!(RECONNECTED, "__RECONNECTED__");
4314 }
4315
4316 #[rstest]
4317 fn reconnect_auth_retry_delay_grows_and_caps() {
4318 assert_eq!(reconnect_auth_retry_delay(1), Duration::from_secs(1));
4319 assert_eq!(reconnect_auth_retry_delay(2), Duration::from_secs(2));
4320 assert_eq!(reconnect_auth_retry_delay(3), Duration::from_secs(4));
4321 assert_eq!(reconnect_auth_retry_delay(6), Duration::from_secs(30));
4322 assert_eq!(
4323 reconnect_auth_retry_delay(u32::MAX),
4324 Duration::from_secs(30)
4325 );
4326 }
4327
4328 #[rstest]
4329 fn resubscribe_batches_subscription_args_into_venue_message_size() {
4330 let args: Vec<_> = (0..300)
4331 .map(|i| OKXSubscriptionArg {
4332 channel: OKXWsChannel::Tickers,
4333 inst_type: None,
4334 inst_family: None,
4335 inst_id: Some(Ustr::from(&format!("INST-{i}"))),
4336 })
4337 .collect();
4338
4339 let batches: Vec<_> = subscription_arg_batches(&args).collect();
4340
4341 assert_eq!(OKX_WS_SUBSCRIPTION_ARGS_MAX_PER_MESSAGE, 256);
4342 assert_eq!(batches.len(), 2);
4343 assert_eq!(batches[0].len(), 256);
4344 assert_eq!(batches[1].len(), 44);
4345 }
4346
4347 #[rstest]
4348 #[tokio::test]
4349 async fn authenticate_session_sends_login_and_completes_on_success() {
4350 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4351 let cmd_tx = tokio::sync::RwLock::new(tx);
4352 let auth_tracker = AuthTracker::new();
4353 let credential = Credential::new("key".into(), "secret".into(), "pass".into());
4354 let clock = get_atomic_clock_realtime();
4355 let tracker = auth_tracker.clone();
4356
4357 let task = tokio::spawn(async move {
4358 authenticate_session(&credential, &auth_tracker, &cmd_tx, clock, 5).await
4359 });
4360
4361 match rx.recv().await.expect("authenticate command") {
4362 HandlerCommand::Authenticate { .. } => {}
4363 other => panic!("Expected HandlerCommand::Authenticate, was {other:?}"),
4364 }
4365
4366 tracker.succeed();
4367 task.await
4368 .expect("authenticate task")
4369 .expect("authentication should succeed");
4370 }
4371
4372 #[rstest]
4373 fn auth_attempt_superseded_matches_tracker_message() {
4374 let error = Error::Io(std::io::Error::other("Authentication attempt superseded"));
4375 assert!(auth_attempt_superseded(&error));
4376 let other = Error::Io(std::io::Error::other("Authentication timed out"));
4377 assert!(!auth_attempt_superseded(&other));
4378 }
4379
4380 #[rstest]
4381 #[tokio::test]
4382 async fn retry_reconnect_authentication_stops_when_already_authenticated() {
4383 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4384 let cmd_tx = Arc::new(tokio::sync::RwLock::new(tx));
4385 let auth_tracker = AuthTracker::new();
4386 auth_tracker.succeed();
4387
4388 tokio::time::timeout(
4389 Duration::from_millis(200),
4390 retry_reconnect_authentication(
4391 Credential::new("key".into(), "secret".into(), "pass".into()),
4392 auth_tracker,
4393 cmd_tx,
4394 get_atomic_clock_realtime(),
4395 5,
4396 Arc::new(AtomicBool::new(false)),
4397 CancellationToken::new(),
4398 Arc::new(AtomicU64::new(1)),
4399 1,
4400 ),
4401 )
4402 .await
4403 .expect("authenticated retry must return without sending login");
4404 assert!(
4405 rx.try_recv().is_err(),
4406 "already-authenticated retry must not send a login command"
4407 );
4408 }
4409
4410 #[rstest]
4411 #[tokio::test]
4412 async fn retry_reconnect_authentication_stops_when_aborted() {
4413 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4414 let cmd_tx = Arc::new(tokio::sync::RwLock::new(tx));
4415 let abort = CancellationToken::new();
4416 abort.cancel();
4417
4418 tokio::time::timeout(
4419 Duration::from_millis(200),
4420 retry_reconnect_authentication(
4421 Credential::new("key".into(), "secret".into(), "pass".into()),
4422 AuthTracker::new(),
4423 cmd_tx,
4424 get_atomic_clock_realtime(),
4425 5,
4426 Arc::new(AtomicBool::new(false)),
4427 abort,
4428 Arc::new(AtomicU64::new(1)),
4429 1,
4430 ),
4431 )
4432 .await
4433 .expect("aborted retry must return without waiting for authentication");
4434 }
4435
4436 #[rstest]
4437 fn test_multiple_reconnection_signals() {
4438 for _ in 0..3 {
4439 let msg = Message::Text(RECONNECTED.to_string().into());
4440 let result = OKXWsFeedHandler::parse_raw_message(msg);
4441 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4442 }
4443 }
4444
4445 #[tokio::test]
4446 async fn test_wait_until_active_timeout() {
4447 let client = OKXWebSocketClient::new(
4448 None,
4449 Some("test_key".to_string()),
4450 Some("test_secret".to_string()),
4451 Some("test_passphrase".to_string()),
4452 Some(AccountId::from("test-account")),
4453 None,
4454 None,
4455 TransportBackend::default(),
4456 None,
4457 )
4458 .unwrap();
4459
4460 let result = client.wait_until_active(0.1).await;
4461
4462 assert!(result.is_err());
4463 assert!(!client.is_active());
4464 }
4465
4466 fn sample_canceled_order_msg() -> OKXOrderMsg {
4467 OKXOrderMsg {
4468 acc_fill_sz: Some("0".to_string()),
4469 avg_px: "0".to_string(),
4470 c_time: 0,
4471 cancel_source: None,
4472 cancel_source_reason: None,
4473 category: OKXOrderCategory::Normal,
4474 ccy: Ustr::from("USDT"),
4475 cl_ord_id: "order-1".to_string(),
4476 algo_cl_ord_id: None,
4477 attach_algo_cl_ord_id: None,
4478 attach_algo_ords: Vec::new(),
4479 outcome: None,
4480 fee: None,
4481 fee_ccy: Ustr::from("USDT"),
4482 fill_px: "0".to_string(),
4483 fill_sz: "0".to_string(),
4484 fill_time: 0,
4485 inst_id: Ustr::from("ETH-USDT-SWAP"),
4486 inst_type: OKXInstrumentType::Swap,
4487 lever: "1".to_string(),
4488 ord_id: Ustr::from("123456"),
4489 ord_type: OKXOrderType::Limit,
4490 pnl: "0".to_string(),
4491 pos_side: OKXPositionSide::Net,
4492 px: "0".to_string(),
4493 reduce_only: "false".to_string(),
4494 side: OKXSide::Buy,
4495 state: OKXOrderStatus::Canceled,
4496 exec_type: OKXExecType::None,
4497 sz: "1".to_string(),
4498 td_mode: OKXTradeMode::Cross,
4499 tgt_ccy: None,
4500 trade_id: String::new(),
4501 algo_id: None,
4502 fill_fee: None,
4503 fill_fee_ccy: None,
4504 fill_mark_px: None,
4505 fill_mark_vol: None,
4506 fill_px_vol: None,
4507 fill_px_usd: None,
4508 fill_fwd_px: None,
4509 fill_notional_usd: None,
4510 fill_pnl: None,
4511 is_tp_limit: None,
4512 linked_algo_ord: None,
4513 notional_usd: None,
4514 px_type: OKXPriceType::None,
4515 px_usd: None,
4516 px_vol: None,
4517 quick_mgn_type: OKXQuickMarginType::None,
4518 rebate: None,
4519 rebate_ccy: None,
4520 sl_ord_px: None,
4521 sl_trigger_px: None,
4522 sl_trigger_px_type: None,
4523 source: None,
4524 stp_id: None,
4525 stp_mode: OKXSelfTradePreventionMode::None,
4526 tag: None,
4527 tp_ord_px: None,
4528 tp_trigger_px: None,
4529 tp_trigger_px_type: None,
4530 amend_result: None,
4531 req_id: None,
4532 code: None,
4533 msg: None,
4534 u_time: 0,
4535 }
4536 }
4537
4538 #[rstest]
4539 fn test_is_post_only_auto_cancel_detects_cancel_source() {
4540 let mut msg = sample_canceled_order_msg();
4541 msg.cancel_source = Some(OKX_POST_ONLY_CANCEL_SOURCE.to_string());
4542
4543 assert!(is_post_only_auto_cancel(&msg));
4544 }
4545
4546 #[rstest]
4547 fn test_is_post_only_auto_cancel_detects_reason() {
4548 let mut msg = sample_canceled_order_msg();
4549 msg.cancel_source_reason = Some("POST_ONLY would take liquidity".to_string());
4550
4551 assert!(is_post_only_auto_cancel(&msg));
4552 }
4553
4554 #[rstest]
4555 fn test_is_post_only_auto_cancel_false_without_markers() {
4556 let msg = sample_canceled_order_msg();
4557
4558 assert!(!is_post_only_auto_cancel(&msg));
4559 }
4560
4561 #[rstest]
4562 fn test_is_post_only_auto_cancel_false_for_order_type_only() {
4563 let mut msg = sample_canceled_order_msg();
4564 msg.ord_type = OKXOrderType::PostOnly;
4565
4566 assert!(!is_post_only_auto_cancel(&msg));
4567 }
4568
4569 #[tokio::test]
4570 async fn test_batch_cancel_orders_with_multiple_orders() {
4571 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4572
4573 let client = OKXWebSocketClient::new(
4574 Some("wss://test.okx.com".to_string()),
4575 None,
4576 None,
4577 None,
4578 None,
4579 None,
4580 None,
4581 TransportBackend::default(),
4582 None,
4583 )
4584 .expect("Failed to create client");
4585
4586 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4587 let client_order_id1 = ClientOrderId::new("order1");
4588 let client_order_id2 = ClientOrderId::new("order2");
4589 let venue_order_id1 = VenueOrderId::new("venue1");
4590 let venue_order_id2 = VenueOrderId::new("venue2");
4591
4592 let orders = vec![
4593 (instrument_id, Some(client_order_id1), Some(venue_order_id1)),
4594 (instrument_id, Some(client_order_id2), Some(venue_order_id2)),
4595 ];
4596
4597 let result = client.batch_cancel_orders(orders).await;
4598 assert!(result.is_err());
4599 }
4600
4601 #[tokio::test]
4602 async fn test_batch_cancel_orders_with_only_client_order_id() {
4603 use nautilus_model::identifiers::{ClientOrderId, InstrumentId};
4604
4605 let client = OKXWebSocketClient::new(
4606 Some("wss://test.okx.com".to_string()),
4607 None,
4608 None,
4609 None,
4610 None,
4611 None,
4612 None,
4613 TransportBackend::default(),
4614 None,
4615 )
4616 .expect("Failed to create client");
4617
4618 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4619 let client_order_id = ClientOrderId::new("order1");
4620
4621 let orders = vec![(instrument_id, Some(client_order_id), None)];
4622
4623 let result = client.batch_cancel_orders(orders).await;
4624
4625 assert!(result.is_err());
4626 }
4627
4628 #[tokio::test]
4629 async fn test_batch_cancel_orders_with_only_venue_order_id() {
4630 use nautilus_model::identifiers::{InstrumentId, VenueOrderId};
4631
4632 let client = OKXWebSocketClient::new(
4633 Some("wss://test.okx.com".to_string()),
4634 None,
4635 None,
4636 None,
4637 None,
4638 None,
4639 None,
4640 TransportBackend::default(),
4641 None,
4642 )
4643 .expect("Failed to create client");
4644
4645 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4646 let venue_order_id = VenueOrderId::new("venue1");
4647
4648 let orders = vec![(instrument_id, None, Some(venue_order_id))];
4649
4650 let result = client.batch_cancel_orders(orders).await;
4651
4652 assert!(result.is_err());
4653 }
4654
4655 #[tokio::test]
4656 async fn test_batch_cancel_orders_with_both_ids() {
4657 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4658
4659 let client = OKXWebSocketClient::new(
4660 Some("wss://test.okx.com".to_string()),
4661 None,
4662 None,
4663 None,
4664 None,
4665 None,
4666 None,
4667 TransportBackend::default(),
4668 None,
4669 )
4670 .expect("Failed to create client");
4671
4672 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4673 let client_order_id = ClientOrderId::new("order1");
4674 let venue_order_id = VenueOrderId::new("venue1");
4675
4676 let orders = vec![(instrument_id, Some(client_order_id), Some(venue_order_id))];
4677
4678 let result = client.batch_cancel_orders(orders).await;
4679
4680 assert!(result.is_err());
4681 }
4682
4683 #[tokio::test]
4684 async fn test_cancel_order_fails_without_inst_id_code() {
4685 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4686
4687 let client = OKXWebSocketClient::default();
4688 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4689
4690 let result = client
4691 .cancel_order(
4692 TraderId::from("TESTER-001"),
4693 StrategyId::from("S-001"),
4694 instrument_id,
4695 Some(ClientOrderId::new("O-001")),
4696 None,
4697 )
4698 .await;
4699
4700 assert!(result.is_err());
4701 let err = result.unwrap_err().to_string();
4702 assert!(
4703 err.contains("No instIdCode cached for BTC-USDT-SWAP.OKX"),
4704 "Expected instIdCode error, found: {err}"
4705 );
4706 }
4707
4708 #[tokio::test]
4709 async fn test_submit_order_fails_without_inst_id_code() {
4710 use nautilus_model::{
4711 enums::{OrderSide, OrderType},
4712 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
4713 types::Quantity,
4714 };
4715
4716 use crate::common::enums::OKXTradeMode;
4717
4718 let client = OKXWebSocketClient::default();
4719 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
4720
4721 let result = client
4722 .submit_order(
4723 TraderId::from("TESTER-001"),
4724 StrategyId::from("S-001"),
4725 instrument_id,
4726 OKXTradeMode::Cross,
4727 ClientOrderId::new("O-001"),
4728 OrderSide::Buy,
4729 OrderType::Limit,
4730 Quantity::from("0.01"),
4731 None,
4732 None,
4733 None,
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 )
4747 .await;
4748
4749 assert!(result.is_err());
4750 let err = result.unwrap_err().to_string();
4751 assert!(
4752 err.contains("No instIdCode cached for ETH-USDT-SWAP.OKX"),
4753 "Expected instIdCode error, found: {err}"
4754 );
4755 }
4756
4757 #[tokio::test]
4758 async fn test_cancel_order_passes_inst_id_code_lookup_when_cached() {
4759 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4760 use ustr::Ustr;
4761
4762 let client = OKXWebSocketClient::default();
4763 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4764
4765 client.cache_inst_id_code(Ustr::from("BTC-USDT-SWAP"), 10459);
4767
4768 let result = client
4769 .cancel_order(
4770 TraderId::from("TESTER-001"),
4771 StrategyId::from("S-001"),
4772 instrument_id,
4773 Some(ClientOrderId::new("O-001")),
4774 None,
4775 )
4776 .await;
4777
4778 assert!(result.is_err());
4780 let err = result.unwrap_err().to_string();
4781 assert!(
4782 !err.contains("No instIdCode cached"),
4783 "Should pass instIdCode lookup, found: {err}"
4784 );
4785 }
4786
4787 #[rstest]
4788 fn test_inst_id_code_cache_keeps_usd_and_usdc_instruments_distinct() {
4789 use ustr::Ustr;
4790
4791 let client = OKXWebSocketClient::default();
4792 client.cache_inst_id_code(Ustr::from("BTC-USD"), 10401);
4793 client.cache_inst_id_code(Ustr::from("BTC-USDC"), 20459);
4794
4795 assert_eq!(client.get_inst_id_code(&Ustr::from("BTC-USD")), Some(10401));
4796 assert_eq!(
4797 client.get_inst_id_code(&Ustr::from("BTC-USDC")),
4798 Some(20459)
4799 );
4800 assert_ne!(
4801 client.get_inst_id_code(&Ustr::from("BTC-USD")),
4802 client.get_inst_id_code(&Ustr::from("BTC-USDC"))
4803 );
4804 }
4805
4806 #[rstest]
4807 fn test_race_unsubscribe_failure_recovery() {
4808 let client = OKXWebSocketClient::new(
4814 Some("wss://test.okx.com".to_string()),
4815 None,
4816 None,
4817 None,
4818 None,
4819 None,
4820 None,
4821 TransportBackend::default(),
4822 None,
4823 )
4824 .expect("Failed to create client");
4825
4826 let topic = "trades:BTC-USDT-SWAP";
4827
4828 client.subscriptions_state.mark_subscribe(topic);
4830 client.subscriptions_state.confirm_subscribe(topic);
4831 assert_eq!(client.subscriptions_state.len(), 1);
4832
4833 client.subscriptions_state.mark_unsubscribe(topic);
4835 assert_eq!(client.subscriptions_state.len(), 0);
4836 assert_eq!(
4837 client.subscriptions_state.pending_unsubscribe_topics(),
4838 vec![topic]
4839 );
4840
4841 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);
4849 assert!(
4850 client
4851 .subscriptions_state
4852 .pending_unsubscribe_topics()
4853 .is_empty()
4854 );
4855 assert!(
4856 client
4857 .subscriptions_state
4858 .pending_subscribe_topics()
4859 .is_empty()
4860 );
4861
4862 let all = client.subscriptions_state.all_topics();
4864 assert_eq!(all.len(), 1);
4865 assert!(all.contains(&topic.to_string()));
4866 }
4867
4868 #[rstest]
4869 fn test_race_resubscribe_before_unsubscribe_ack() {
4870 let client = OKXWebSocketClient::new(
4874 Some("wss://test.okx.com".to_string()),
4875 None,
4876 None,
4877 None,
4878 None,
4879 None,
4880 None,
4881 TransportBackend::default(),
4882 None,
4883 )
4884 .expect("Failed to create client");
4885
4886 let topic = "books:BTC-USDT";
4887
4888 client.subscriptions_state.mark_subscribe(topic);
4890 client.subscriptions_state.confirm_subscribe(topic);
4891 assert_eq!(client.subscriptions_state.len(), 1);
4892
4893 client.subscriptions_state.mark_unsubscribe(topic);
4895 assert_eq!(client.subscriptions_state.len(), 0);
4896 assert_eq!(
4897 client.subscriptions_state.pending_unsubscribe_topics(),
4898 vec![topic]
4899 );
4900
4901 client.subscriptions_state.mark_subscribe(topic);
4903 assert_eq!(
4904 client.subscriptions_state.pending_subscribe_topics(),
4905 vec![topic]
4906 );
4907
4908 client.subscriptions_state.confirm_unsubscribe(topic);
4910 assert!(
4911 client
4912 .subscriptions_state
4913 .pending_unsubscribe_topics()
4914 .is_empty()
4915 );
4916 assert_eq!(
4917 client.subscriptions_state.pending_subscribe_topics(),
4918 vec![topic]
4919 );
4920
4921 client.subscriptions_state.confirm_subscribe(topic);
4923 assert_eq!(client.subscriptions_state.len(), 1);
4924 assert!(
4925 client
4926 .subscriptions_state
4927 .pending_subscribe_topics()
4928 .is_empty()
4929 );
4930
4931 let all = client.subscriptions_state.all_topics();
4933 assert_eq!(all.len(), 1);
4934 assert!(all.contains(&topic.to_string()));
4935 }
4936
4937 #[rstest]
4938 fn test_race_late_subscribe_confirmation_after_unsubscribe() {
4939 let client = OKXWebSocketClient::new(
4942 Some("wss://test.okx.com".to_string()),
4943 None,
4944 None,
4945 None,
4946 None,
4947 None,
4948 None,
4949 TransportBackend::default(),
4950 None,
4951 )
4952 .expect("Failed to create client");
4953
4954 let topic = "tickers:ETH-USDT";
4955
4956 client.subscriptions_state.mark_subscribe(topic);
4958 assert_eq!(
4959 client.subscriptions_state.pending_subscribe_topics(),
4960 vec![topic]
4961 );
4962
4963 client.subscriptions_state.mark_unsubscribe(topic);
4965 assert!(
4966 client
4967 .subscriptions_state
4968 .pending_subscribe_topics()
4969 .is_empty()
4970 ); assert_eq!(
4972 client.subscriptions_state.pending_unsubscribe_topics(),
4973 vec![topic]
4974 );
4975
4976 client.subscriptions_state.confirm_subscribe(topic);
4978 assert_eq!(client.subscriptions_state.len(), 0); assert_eq!(
4980 client.subscriptions_state.pending_unsubscribe_topics(),
4981 vec![topic]
4982 );
4983
4984 client.subscriptions_state.confirm_unsubscribe(topic);
4986
4987 assert!(client.subscriptions_state.is_empty());
4989 assert!(client.subscriptions_state.all_topics().is_empty());
4990 }
4991
4992 #[rstest]
4993 fn test_race_reconnection_with_pending_states() {
4994 let client = OKXWebSocketClient::new(
4996 Some("wss://test.okx.com".to_string()),
4997 Some("test_key".to_string()),
4998 Some("test_secret".to_string()),
4999 Some("test_passphrase".to_string()),
5000 Some(AccountId::new("OKX-TEST")),
5001 None,
5002 None,
5003 TransportBackend::default(),
5004 None,
5005 )
5006 .expect("Failed to create client");
5007
5008 let trade_btc = "trades:BTC-USDT-SWAP";
5011 client.subscriptions_state.mark_subscribe(trade_btc);
5012 client.subscriptions_state.confirm_subscribe(trade_btc);
5013
5014 let trade_eth = "trades:ETH-USDT-SWAP";
5016 client.subscriptions_state.mark_subscribe(trade_eth);
5017
5018 let book_btc = "books:BTC-USDT";
5020 client.subscriptions_state.mark_subscribe(book_btc);
5021 client.subscriptions_state.confirm_subscribe(book_btc);
5022 client.subscriptions_state.mark_unsubscribe(book_btc);
5023
5024 let topics_to_restore = client.subscriptions_state.all_topics();
5026
5027 assert_eq!(topics_to_restore.len(), 2);
5029 assert!(topics_to_restore.contains(&trade_btc.to_string()));
5030 assert!(topics_to_restore.contains(&trade_eth.to_string()));
5031 assert!(!topics_to_restore.contains(&book_btc.to_string())); }
5033
5034 #[rstest]
5035 fn test_race_duplicate_subscribe_messages_idempotent() {
5036 let client = OKXWebSocketClient::new(
5039 Some("wss://test.okx.com".to_string()),
5040 None,
5041 None,
5042 None,
5043 None,
5044 None,
5045 None,
5046 TransportBackend::default(),
5047 None,
5048 )
5049 .expect("Failed to create client");
5050
5051 let topic = "trades:BTC-USDT-SWAP";
5052
5053 client.subscriptions_state.mark_subscribe(topic);
5055 client.subscriptions_state.confirm_subscribe(topic);
5056 assert_eq!(client.subscriptions_state.len(), 1);
5057
5058 client.subscriptions_state.mark_subscribe(topic);
5060 assert!(
5061 client
5062 .subscriptions_state
5063 .pending_subscribe_topics()
5064 .is_empty()
5065 ); assert_eq!(client.subscriptions_state.len(), 1); client.subscriptions_state.confirm_subscribe(topic);
5070 assert_eq!(client.subscriptions_state.len(), 1);
5071
5072 let all = client.subscriptions_state.all_topics();
5074 assert_eq!(all.len(), 1);
5075 assert_eq!(all[0], topic);
5076 }
5077}