1use std::{
26 fmt::Debug,
27 num::NonZeroU32,
28 sync::{
29 Arc, LazyLock,
30 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
31 },
32 time::{Duration, SystemTime},
33};
34
35use ahash::{AHashMap, AHashSet};
36use arc_swap::ArcSwap;
37use dashmap::DashMap;
38use futures_util::Stream;
39use nautilus_common::live::get_runtime;
40use nautilus_core::{
41 AtomicMap,
42 consts::NAUTILUS_USER_AGENT,
43 env::{get_env_var, get_or_env_var},
44 string::secret::REDACTED,
45};
46use nautilus_model::{
47 data::BarType,
48 enums::{OrderSide, OrderType, PositionSide, TimeInForce, TriggerType},
49 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
50 instruments::{Instrument, InstrumentAny},
51 types::{Price, Quantity},
52};
53use nautilus_network::{
54 http::USER_AGENT,
55 mode::ConnectionMode,
56 ratelimiter::quota::Quota,
57 websocket::{
58 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, PingHandler, SubscriptionState, TEXT_PING,
59 TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
60 },
61};
62use serde_json::Value;
63use tokio_tungstenite::tungstenite::Error;
64use tokio_util::sync::CancellationToken;
65use ustr::Ustr;
66
67use super::{
68 enums::OKXWsChannel,
69 error::OKXWsError,
70 handler::{HandlerCommand, OKXWsFeedHandler},
71 messages::{
72 OKXAuthentication, OKXAuthenticationArg, OKXSubscriptionArg, OKXWsMessage, OKXWsRequest,
73 WsAmendOrderParamsBuilder, WsAttachAlgoOrdParams, WsCancelOrderParamsBuilder,
74 WsMassCancelParams, WsPostAlgoOrderParamsBuilder, WsPostOrderParamsBuilder,
75 },
76 subscription::topic_from_subscription_arg,
77};
78use crate::common::{
79 consts::{
80 OKX_NAUTILUS_BROKER_ID, OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
81 OKX_WS_PUBLIC_URL, OKX_WS_TOPIC_DELIMITER,
82 },
83 credential::Credential,
84 enums::{
85 OKXGreeksType, OKXInstrumentType, OKXOrderType, OKXPositionSide, OKXTargetCurrency,
86 OKXTradeMode, OKXTriggerType, OKXVipLevel, conditional_order_to_algo_type,
87 is_conditional_order,
88 },
89 parse::{
90 bar_spec_as_okx_channel, okx_instrument_type, okx_instrument_type_from_symbol,
91 parse_base_quote_from_symbol,
92 },
93};
94
95pub static OKX_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
99 Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant")
100});
101
102pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
107 LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).expect("non-zero")));
108
109pub static OKX_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
111 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
112});
113
114pub static OKX_WS_BATCH_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
116 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant")
117});
118
119pub static OKX_WS_MASS_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
121 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
122});
123
124pub static OKX_WS_ALGO_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
126 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
127});
128
129pub static OKX_WS_ALGO_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
131 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
132});
133
134pub static OKX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
139 LazyLock::new(|| [Ustr::from("subscription")]);
140
141pub static OKX_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("order")]);
145
146pub static OKX_RATE_LIMIT_KEY_BATCH_ORDER: LazyLock<[Ustr; 1]> =
150 LazyLock::new(|| [Ustr::from("batch-order")]);
151
152pub static OKX_RATE_LIMIT_KEY_CANCEL: LazyLock<[Ustr; 1]> =
156 LazyLock::new(|| [Ustr::from("cancel")]);
157
158pub static OKX_RATE_LIMIT_KEY_BATCH_CANCEL: LazyLock<[Ustr; 1]> =
162 LazyLock::new(|| [Ustr::from("batch-cancel")]);
163
164pub static OKX_RATE_LIMIT_KEY_MASS_CANCEL: LazyLock<[Ustr; 1]> =
168 LazyLock::new(|| [Ustr::from("mass-cancel")]);
169
170pub static OKX_RATE_LIMIT_KEY_AMEND: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("amend")]);
174
175pub static OKX_RATE_LIMIT_KEY_BATCH_AMEND: LazyLock<[Ustr; 1]> =
179 LazyLock::new(|| [Ustr::from("batch-amend")]);
180
181pub static OKX_RATE_LIMIT_KEY_ALGO_ORDER: LazyLock<[Ustr; 1]> =
185 LazyLock::new(|| [Ustr::from("algo-order")]);
186
187pub static OKX_RATE_LIMIT_KEY_ALGO_CANCEL: LazyLock<[Ustr; 1]> =
191 LazyLock::new(|| [Ustr::from("algo-cancel")]);
192
193#[derive(Debug, Clone)]
197#[allow(dead_code)]
198pub(crate) struct PendingOrderInfo {
199 pub trader_id: TraderId,
200 pub strategy_id: StrategyId,
201 pub instrument_id: InstrumentId,
202}
203
204#[derive(Clone)]
206#[cfg_attr(
207 feature = "python",
208 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.okx", from_py_object)
209)]
210#[cfg_attr(
211 feature = "python",
212 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
213)]
214pub struct OKXWebSocketClient {
215 url: String,
216 #[allow(dead_code)] pub(crate) account_id: AccountId,
218 vip_level: Arc<AtomicU8>,
219 credential: Option<Credential>,
220 heartbeat: Option<u64>,
221 auth_timeout_secs: u64,
222 auth_tracker: AuthTracker,
223 signal: Arc<AtomicBool>,
224 connection_mode: Arc<ArcSwap<AtomicU8>>,
225 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
226 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<OKXWsMessage>>>,
227 task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
228 subscriptions_inst_type: Arc<DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>>,
229 subscriptions_inst_family: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
230 subscriptions_inst_id: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
231 subscriptions_bare: Arc<DashMap<OKXWsChannel, bool>>,
232 subscriptions_state: SubscriptionState,
233 request_id_counter: Arc<AtomicU64>,
234 instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
235 inst_id_code_cache: Arc<AtomicMap<Ustr, u64>>,
236 pub(crate) pending_orders: Arc<DashMap<String, PendingOrderInfo>>,
237 pub(crate) pending_cancels: Arc<DashMap<String, PendingOrderInfo>>,
238 pub(crate) pending_amends: Arc<DashMap<String, PendingOrderInfo>>,
239 option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
240 index_pair_subscribers: Arc<DashMap<Ustr, usize>>,
247 index_pair_transition: Arc<tokio::sync::Mutex<()>>,
252 transport_backend: TransportBackend,
254 proxy_url: Option<String>,
256 cancellation_token: CancellationToken,
257}
258
259impl Default for OKXWebSocketClient {
260 fn default() -> Self {
261 Self::new(
262 None,
263 None,
264 None,
265 None,
266 None,
267 None,
268 None,
269 TransportBackend::default(),
270 None,
271 )
272 .unwrap()
273 }
274}
275
276impl Debug for OKXWebSocketClient {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.debug_struct(stringify!(OKXWebSocketClient))
279 .field("url", &self.url)
280 .field("credential", &self.credential.as_ref().map(|_| REDACTED))
281 .field("heartbeat", &self.heartbeat)
282 .finish_non_exhaustive()
283 }
284}
285
286impl OKXWebSocketClient {
287 #[allow(clippy::too_many_arguments)]
293 pub fn new(
294 url: Option<String>,
295 api_key: Option<String>,
296 api_secret: Option<String>,
297 api_passphrase: Option<String>,
298 account_id: Option<AccountId>,
299 heartbeat: Option<u64>,
300 auth_timeout_secs: Option<u64>,
301 transport_backend: TransportBackend,
302 proxy_url: Option<String>,
303 ) -> anyhow::Result<Self> {
304 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
305 let account_id = account_id.unwrap_or(AccountId::from("OKX-master"));
306
307 let credential = match (api_key, api_secret, api_passphrase) {
308 (Some(key), Some(secret), Some(passphrase)) => {
309 Some(Credential::new(key, secret, passphrase))
310 }
311 (None, None, None) => None,
312 _ => anyhow::bail!(
313 "`api_key`, `api_secret`, `api_passphrase` credentials must be provided together"
314 ),
315 };
316
317 let signal = Arc::new(AtomicBool::new(false));
318 let subscriptions_inst_type = Arc::new(DashMap::new());
319 let subscriptions_inst_family = Arc::new(DashMap::new());
320 let subscriptions_inst_id = Arc::new(DashMap::new());
321 let subscriptions_bare = Arc::new(DashMap::new());
322 let subscriptions_state = SubscriptionState::new(OKX_WS_TOPIC_DELIMITER);
323
324 Ok(Self {
325 url,
326 account_id,
327 vip_level: Arc::new(AtomicU8::new(0)),
328 credential,
329 heartbeat,
330 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
331 auth_tracker: AuthTracker::new(),
332 signal,
333 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
334 ConnectionMode::Closed.as_u8(),
335 ))),
336 cmd_tx: {
337 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
339 Arc::new(tokio::sync::RwLock::new(tx))
340 },
341 out_rx: None,
342 task_handle: None,
343 subscriptions_inst_type,
344 subscriptions_inst_family,
345 subscriptions_inst_id,
346 subscriptions_bare,
347 subscriptions_state,
348 request_id_counter: Arc::new(AtomicU64::new(1)),
349 instruments_cache: Arc::new(AtomicMap::new()),
350 inst_id_code_cache: Arc::new(AtomicMap::new()),
351 pending_orders: Arc::new(DashMap::new()),
352 pending_cancels: Arc::new(DashMap::new()),
353 pending_amends: Arc::new(DashMap::new()),
354 option_greeks_subs: Arc::new(AtomicMap::new()),
355 index_pair_subscribers: Arc::new(DashMap::new()),
356 index_pair_transition: Arc::new(tokio::sync::Mutex::new(())),
357 transport_backend,
358 proxy_url,
359 cancellation_token: CancellationToken::new(),
360 })
361 }
362
363 #[allow(clippy::too_many_arguments)]
370 pub fn with_credentials(
371 url: Option<String>,
372 api_key: Option<String>,
373 api_secret: Option<String>,
374 api_passphrase: Option<String>,
375 account_id: Option<AccountId>,
376 heartbeat: Option<u64>,
377 auth_timeout_secs: Option<u64>,
378 transport_backend: TransportBackend,
379 proxy_url: Option<String>,
380 ) -> anyhow::Result<Self> {
381 let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
382 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
383 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
384 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
385
386 Self::new(
387 Some(url),
388 Some(api_key),
389 Some(api_secret),
390 Some(api_passphrase),
391 account_id,
392 heartbeat,
393 auth_timeout_secs,
394 transport_backend,
395 proxy_url,
396 )
397 }
398
399 pub fn from_env() -> anyhow::Result<Self> {
406 let url = get_env_var("OKX_WS_URL")?;
407 let api_key = get_env_var("OKX_API_KEY")?;
408 let api_secret = get_env_var("OKX_API_SECRET")?;
409 let api_passphrase = get_env_var("OKX_API_PASSPHRASE")?;
410
411 Self::new(
412 Some(url),
413 Some(api_key),
414 Some(api_secret),
415 Some(api_passphrase),
416 None,
417 None,
418 None,
419 TransportBackend::default(),
420 None,
421 )
422 }
423
424 pub fn cancel_all_requests(&self) {
426 self.cancellation_token.cancel();
427 }
428
429 pub fn cancellation_token(&self) -> &CancellationToken {
431 &self.cancellation_token
432 }
433
434 pub fn url(&self) -> &str {
436 self.url.as_str()
437 }
438
439 pub fn api_key(&self) -> Option<&str> {
441 self.credential.as_ref().map(|c| c.api_key())
442 }
443
444 #[must_use]
446 pub fn api_key_masked(&self) -> Option<String> {
447 self.credential.as_ref().map(|c| c.api_key_masked())
448 }
449
450 pub fn is_active(&self) -> bool {
452 let connection_mode_arc = self.connection_mode.load();
453 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
454 && !self.signal.load(Ordering::Acquire)
455 }
456
457 pub fn is_closed(&self) -> bool {
459 let connection_mode_arc = self.connection_mode.load();
460 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
461 || self.signal.load(Ordering::Acquire)
462 }
463
464 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
468 self.instruments_cache.rcu(|m| {
469 for inst in instruments {
470 m.insert(inst.symbol().inner(), inst.clone());
471 }
472 });
473 }
474
475 pub fn cache_instrument(&self, instrument: InstrumentAny) {
479 self.instruments_cache
480 .insert(instrument.symbol().inner(), instrument);
481 }
482
483 pub fn instruments_snapshot(&self) -> AHashMap<Ustr, InstrumentAny> {
485 (**self.instruments_cache.load()).clone()
486 }
487
488 pub fn instruments_cache_arc(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
490 Arc::clone(&self.instruments_cache)
491 }
492
493 pub fn cache_inst_id_code(&self, inst_id: Ustr, inst_id_code: u64) {
497 self.inst_id_code_cache.insert(inst_id, inst_id_code);
498 }
499
500 pub fn cache_inst_id_codes(&self, mappings: impl IntoIterator<Item = (Ustr, u64)>) {
504 let entries: Vec<_> = mappings.into_iter().collect();
505 self.inst_id_code_cache.rcu(|m| {
506 for (inst_id, inst_id_code) in &entries {
507 m.insert(*inst_id, *inst_id_code);
508 }
509 });
510 }
511
512 #[must_use]
516 pub fn get_inst_id_code(&self, inst_id: &Ustr) -> Option<u64> {
517 self.inst_id_code_cache.load().get(inst_id).copied()
518 }
519
520 fn inst_id_symbol_and_code_from_snapshot(
521 inst_id_codes: &AHashMap<Ustr, u64>,
522 inst_id: &InstrumentId,
523 action: &str,
524 ) -> Result<(Ustr, u64), OKXWsError> {
525 let inst_id_symbol = inst_id.symbol.inner();
526 let inst_id_code = inst_id_codes.get(&inst_id_symbol).copied().ok_or_else(|| {
527 OKXWsError::ClientError(format!(
528 "No instIdCode cached for {inst_id}, cannot {action} order"
529 ))
530 })?;
531 Ok((inst_id_symbol, inst_id_code))
532 }
533
534 pub fn set_vip_level(&self, vip_level: OKXVipLevel) {
538 self.vip_level.store(vip_level as u8, Ordering::Relaxed);
539 }
540
541 pub fn vip_level(&self) -> OKXVipLevel {
543 let level = self.vip_level.load(Ordering::Relaxed);
544 OKXVipLevel::from(level)
545 }
546
547 pub async fn connect(&mut self) -> anyhow::Result<()> {
557 self.signal.store(false, Ordering::Release);
559
560 let (message_handler, raw_rx) = channel_message_handler();
561
562 let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {
565 });
567
568 let headers = vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())];
569
570 let config = WebSocketConfig {
571 url: self.url.clone(),
572 headers,
573 heartbeat: self.heartbeat,
574 heartbeat_msg: Some(TEXT_PING.to_string()),
575 reconnect_timeout_ms: Some(5_000),
576 reconnect_delay_initial_ms: None,
577 reconnect_delay_max_ms: None,
578 reconnect_backoff_factor: None,
579 reconnect_jitter_ms: None,
580 reconnect_max_attempts: None,
581 idle_timeout_ms: None,
582 backend: self.transport_backend,
583 proxy_url: self.proxy_url.clone(),
584 };
585
586 let keyed_quotas = vec![
587 (
588 OKX_RATE_LIMIT_KEY_SUBSCRIPTION[0].as_str().to_string(),
589 *OKX_WS_SUBSCRIPTION_QUOTA,
590 ),
591 (
592 OKX_RATE_LIMIT_KEY_ORDER[0].as_str().to_string(),
593 *OKX_WS_ORDER_QUOTA,
594 ),
595 (
596 OKX_RATE_LIMIT_KEY_BATCH_ORDER[0].as_str().to_string(),
597 *OKX_WS_BATCH_ORDER_QUOTA,
598 ),
599 (
600 OKX_RATE_LIMIT_KEY_CANCEL[0].as_str().to_string(),
601 *OKX_WS_ORDER_QUOTA,
602 ),
603 (
604 OKX_RATE_LIMIT_KEY_BATCH_CANCEL[0].as_str().to_string(),
605 *OKX_WS_BATCH_ORDER_QUOTA,
606 ),
607 (
608 OKX_RATE_LIMIT_KEY_MASS_CANCEL[0].as_str().to_string(),
609 *OKX_WS_MASS_CANCEL_QUOTA,
610 ),
611 (
612 OKX_RATE_LIMIT_KEY_AMEND[0].as_str().to_string(),
613 *OKX_WS_ORDER_QUOTA,
614 ),
615 (
616 OKX_RATE_LIMIT_KEY_BATCH_AMEND[0].as_str().to_string(),
617 *OKX_WS_BATCH_ORDER_QUOTA,
618 ),
619 (
620 OKX_RATE_LIMIT_KEY_ALGO_ORDER[0].as_str().to_string(),
621 *OKX_WS_ALGO_ORDER_QUOTA,
622 ),
623 (
624 OKX_RATE_LIMIT_KEY_ALGO_CANCEL[0].as_str().to_string(),
625 *OKX_WS_ALGO_CANCEL_QUOTA,
626 ),
627 ];
628
629 let client = WebSocketClient::connect(
630 config,
631 Some(message_handler),
632 Some(ping_handler),
633 None, keyed_quotas,
635 Some(*OKX_WS_CONNECTION_QUOTA), )
637 .await?;
638
639 self.connection_mode.store(client.connection_mode_atomic());
641
642 let (msg_tx, rx) = tokio::sync::mpsc::unbounded_channel::<OKXWsMessage>();
643
644 self.out_rx = Some(Arc::new(rx));
645
646 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
647 *self.cmd_tx.write().await = cmd_tx.clone();
648
649 let signal = self.signal.clone();
650 let auth_tracker = self.auth_tracker.clone();
651 let subscriptions_state = self.subscriptions_state.clone();
652
653 let stream_handle = get_runtime().spawn({
654 let auth_tracker = auth_tracker.clone();
655 let signal = signal.clone();
656 let credential = self.credential.clone();
657 let cmd_tx_for_reconnect = cmd_tx.clone();
658 let subscriptions_bare = self.subscriptions_bare.clone();
659 let subscriptions_inst_type = self.subscriptions_inst_type.clone();
660 let subscriptions_inst_family = self.subscriptions_inst_family.clone();
661 let subscriptions_inst_id = self.subscriptions_inst_id.clone();
662 let mut has_reconnected = false;
663
664 async move {
665 let mut handler = OKXWsFeedHandler::new(
666 signal.clone(),
667 cmd_rx,
668 raw_rx,
669 msg_tx,
670 auth_tracker.clone(),
671 subscriptions_state.clone(),
672 );
673
674 let resubscribe_all = || {
676 for entry in subscriptions_inst_id.iter() {
677 let (channel, inst_ids) = entry.pair();
678 for inst_id in inst_ids {
679 let arg = OKXSubscriptionArg {
680 channel: channel.clone(),
681 inst_type: None,
682 inst_family: None,
683 inst_id: Some(*inst_id),
684 };
685
686 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
687 log::error!("Failed to send resubscribe command: error={e}");
688 }
689 }
690 }
691
692 for entry in subscriptions_bare.iter() {
693 let channel = entry.key();
694 let arg = OKXSubscriptionArg {
695 channel: channel.clone(),
696 inst_type: None,
697 inst_family: None,
698 inst_id: None,
699 };
700
701 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
702 log::error!("Failed to send resubscribe command: error={e}");
703 }
704 }
705
706 for entry in subscriptions_inst_type.iter() {
707 let (channel, inst_types) = entry.pair();
708 for inst_type in inst_types {
709 let arg = OKXSubscriptionArg {
710 channel: channel.clone(),
711 inst_type: Some(*inst_type),
712 inst_family: None,
713 inst_id: None,
714 };
715
716 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
717 log::error!("Failed to send resubscribe command: error={e}");
718 }
719 }
720 }
721
722 for entry in subscriptions_inst_family.iter() {
723 let (channel, inst_families) = entry.pair();
724 for inst_family in inst_families {
725 let arg = OKXSubscriptionArg {
726 channel: channel.clone(),
727 inst_type: None,
728 inst_family: Some(*inst_family),
729 inst_id: None,
730 };
731
732 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] }) {
733 log::error!("Failed to send resubscribe command: error={e}");
734 }
735 }
736 }
737 };
738
739 loop {
740 match handler.next().await {
741 Some(OKXWsMessage::Reconnected) => {
742 if signal.load(Ordering::Acquire) {
743 continue;
744 }
745
746 has_reconnected = true;
747
748 let confirmed_topics_vec: Vec<String> = {
750 let confirmed = subscriptions_state.confirmed();
751 let mut topics = Vec::new();
752
753 for entry in confirmed.iter() {
754 let channel = entry.key();
755 for symbol in entry.value() {
756 if symbol.as_str() == "#" {
757 topics.push(channel.to_string());
758 } else {
759 topics.push(format!("{channel}{OKX_WS_TOPIC_DELIMITER}{symbol}"));
760 }
761 }
762 }
763 topics
764 };
765
766 if !confirmed_topics_vec.is_empty() {
767 log::debug!("Marking confirmed subscriptions as pending for replay: count={}", confirmed_topics_vec.len());
768 for topic in confirmed_topics_vec {
769 subscriptions_state.mark_failure(&topic);
770 }
771 }
772
773 if let Some(cred) = &credential {
774 log::debug!("Re-authenticating after reconnection");
775 let timestamp = std::time::SystemTime::now()
776 .duration_since(std::time::SystemTime::UNIX_EPOCH)
777 .expect("System time should be after UNIX epoch")
778 .as_secs()
779 .to_string();
780 let signature = cred.sign(×tamp, "GET", "/users/self/verify", "");
781
782 let auth_message = super::messages::OKXAuthentication {
783 op: "login",
784 args: vec![super::messages::OKXAuthenticationArg {
785 api_key: cred.api_key().to_string(),
786 passphrase: cred.api_passphrase().to_string(),
787 timestamp,
788 sign: signature,
789 }],
790 };
791
792 if let Ok(payload) = serde_json::to_string(&auth_message) {
793 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Authenticate { payload }) {
794 log::error!("Failed to send reconnection auth command: error={e}");
795 }
796 } else {
797 log::error!("Failed to serialize reconnection auth message");
798 }
799 }
800
801 if credential.is_none() {
804 log::debug!("No authentication required, resubscribing immediately");
805 resubscribe_all();
806 }
807
808 if handler.send(OKXWsMessage::Reconnected).is_err() {
810 log_receiver_dropped(&signal, "Reconnected");
811 break;
812 }
813 }
814 Some(OKXWsMessage::Authenticated) => {
815 if has_reconnected {
816 resubscribe_all();
817 }
818 }
819 Some(msg) => {
820 if handler.send(msg).is_err() {
821 log_receiver_dropped(&signal, "message");
822 break;
823 }
824 }
825 None => {
826 if handler.is_stopped() {
827 log::debug!(
828 "Stop signal received, ending message processing",
829 );
830 break;
831 }
832 log::debug!("WebSocket stream closed");
833 break;
834 }
835 }
836 }
837
838 log::debug!("Handler task exiting");
839 }
840 });
841
842 self.task_handle = Some(Arc::new(stream_handle));
843
844 self.cmd_tx
845 .read()
846 .await
847 .send(HandlerCommand::SetClient(client))
848 .map_err(|e| {
849 OKXWsError::ClientError(format!("Failed to send WebSocket client to handler: {e}"))
850 })?;
851 log::debug!("Sent WebSocket client to handler");
852
853 if self.credential.is_some()
854 && let Err(e) = self.authenticate().await
855 {
856 anyhow::bail!("Authentication failed: {e}");
857 }
858
859 Ok(())
860 }
861
862 async fn authenticate(&self) -> Result<(), Error> {
864 let credential = self.credential.as_ref().ok_or_else(|| {
865 Error::Io(std::io::Error::other(
866 "API credentials not available to authenticate",
867 ))
868 })?;
869
870 let rx = self.auth_tracker.begin();
871
872 let timestamp = SystemTime::now()
873 .duration_since(SystemTime::UNIX_EPOCH)
874 .expect("System time should be after UNIX epoch")
875 .as_secs()
876 .to_string();
877 let signature = credential.sign(×tamp, "GET", "/users/self/verify", "");
878
879 let auth_message = OKXAuthentication {
880 op: "login",
881 args: vec![OKXAuthenticationArg {
882 api_key: credential.api_key().to_string(),
883 passphrase: credential.api_passphrase().to_string(),
884 timestamp,
885 sign: signature,
886 }],
887 };
888
889 let payload = serde_json::to_string(&auth_message).map_err(|e| {
890 Error::Io(std::io::Error::other(format!(
891 "Failed to serialize auth message: {e}"
892 )))
893 })?;
894
895 self.cmd_tx
896 .read()
897 .await
898 .send(HandlerCommand::Authenticate { payload })
899 .map_err(|e| {
900 Error::Io(std::io::Error::other(format!(
901 "Failed to send authenticate command: {e}"
902 )))
903 })?;
904
905 match self
906 .auth_tracker
907 .wait_for_result::<OKXWsError>(Duration::from_secs(self.auth_timeout_secs), rx)
908 .await
909 {
910 Ok(()) => {
911 log::debug!("WebSocket authenticated");
912 Ok(())
913 }
914 Err(e) => {
915 log::error!("WebSocket authentication failed: error={e}");
916 Err(Error::Io(std::io::Error::other(e.to_string())))
917 }
918 }
919 }
920
921 pub fn stream(&mut self) -> impl Stream<Item = OKXWsMessage> + 'static {
929 let rx = self
930 .out_rx
931 .take()
932 .expect("Data stream receiver already taken or not connected");
933 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
934 async_stream::stream! {
935 while let Some(data) = rx.recv().await {
936 yield data;
937 }
938 }
939 }
940
941 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), OKXWsError> {
947 let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
948
949 tokio::time::timeout(timeout, async {
950 while !self.is_active() {
951 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
952 }
953 })
954 .await
955 .map_err(|_| {
956 OKXWsError::ClientError(format!(
957 "WebSocket connection timeout after {timeout_secs} seconds"
958 ))
959 })?;
960
961 Ok(())
962 }
963
964 pub async fn close(&mut self) -> Result<(), Error> {
971 log::debug!("Starting close process");
972
973 self.signal.store(true, Ordering::Release);
974
975 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
976 log::debug!("Handler channel closed before disconnect command was sent: {e}");
977 } else {
978 log::debug!("Sent disconnect command to handler");
979 }
980
981 if let Some(stream_handle) = self.task_handle.take() {
982 match Arc::try_unwrap(stream_handle) {
983 Ok(handle) => {
984 log::debug!("Waiting for stream handle to complete");
985 let abort_handle = handle.abort_handle();
986 match tokio::time::timeout(Duration::from_secs(2), handle).await {
987 Ok(Ok(())) => log::debug!("Stream handle completed successfully"),
988 Ok(Err(e)) => log::error!("Stream handle encountered an error: {e:?}"),
989 Err(_) => {
990 log::warn!("Timeout waiting for stream handle, aborting task");
991 abort_handle.abort();
992 }
993 }
994 }
995 Err(arc_handle) => {
996 log::debug!(
997 "Cannot take ownership of stream handle - other references exist, aborting task"
998 );
999 arc_handle.abort();
1000 }
1001 }
1002 } else {
1003 log::debug!("No stream handle to await");
1004 }
1005
1006 self.index_pair_subscribers.clear();
1010
1011 log::debug!("Close process completed");
1012
1013 Ok(())
1014 }
1015
1016 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<OKXWsChannel> {
1018 let symbol = instrument_id.symbol.inner();
1019 let mut channels = Vec::new();
1020
1021 for entry in self.subscriptions_inst_id.iter() {
1022 let (channel, instruments) = entry.pair();
1023 if instruments.contains(&symbol) {
1024 channels.push(channel.clone());
1025 }
1026 }
1027
1028 channels
1029 }
1030
1031 fn generate_unique_request_id(&self) -> String {
1032 self.request_id_counter
1033 .fetch_add(1, Ordering::SeqCst)
1034 .to_string()
1035 }
1036
1037 async fn subscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1038 self.cmd_tx
1040 .read()
1041 .await
1042 .send(HandlerCommand::Subscribe { args: args.clone() })
1043 .map_err(|e| {
1044 OKXWsError::ClientError(format!("Failed to send subscribe command: {e}"))
1045 })?;
1046
1047 for arg in &args {
1048 let topic = topic_from_subscription_arg(arg);
1049 self.subscriptions_state.mark_subscribe(&topic);
1050
1051 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1053 self.subscriptions_bare.insert(arg.channel.clone(), true);
1054 } else {
1055 if let Some(inst_type) = &arg.inst_type {
1056 self.subscriptions_inst_type
1057 .entry(arg.channel.clone())
1058 .or_default()
1059 .insert(*inst_type);
1060 }
1061
1062 if let Some(inst_family) = &arg.inst_family {
1063 self.subscriptions_inst_family
1064 .entry(arg.channel.clone())
1065 .or_default()
1066 .insert(*inst_family);
1067 }
1068
1069 if let Some(inst_id) = &arg.inst_id {
1070 self.subscriptions_inst_id
1071 .entry(arg.channel.clone())
1072 .or_default()
1073 .insert(*inst_id);
1074 }
1075 }
1076 }
1077
1078 Ok(())
1079 }
1080
1081 #[expect(clippy::collapsible_if)]
1082 async fn unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1083 self.cmd_tx
1085 .read()
1086 .await
1087 .send(HandlerCommand::Unsubscribe { args: args.clone() })
1088 .map_err(|e| {
1089 OKXWsError::ClientError(format!("Failed to send unsubscribe command: {e}"))
1090 })?;
1091
1092 for arg in &args {
1093 let topic = topic_from_subscription_arg(arg);
1094 self.subscriptions_state.mark_unsubscribe(&topic);
1095
1096 if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1097 self.subscriptions_bare.remove(&arg.channel);
1098 } else {
1099 if let Some(inst_type) = &arg.inst_type {
1100 if let Some(mut entry) = self.subscriptions_inst_type.get_mut(&arg.channel) {
1101 entry.remove(inst_type);
1102 if entry.is_empty() {
1103 drop(entry);
1104 self.subscriptions_inst_type.remove(&arg.channel);
1105 }
1106 }
1107 }
1108
1109 if let Some(inst_family) = &arg.inst_family {
1110 if let Some(mut entry) = self.subscriptions_inst_family.get_mut(&arg.channel) {
1111 entry.remove(inst_family);
1112 if entry.is_empty() {
1113 drop(entry);
1114 self.subscriptions_inst_family.remove(&arg.channel);
1115 }
1116 }
1117 }
1118
1119 if let Some(inst_id) = &arg.inst_id {
1120 if let Some(mut entry) = self.subscriptions_inst_id.get_mut(&arg.channel) {
1121 entry.remove(inst_id);
1122 if entry.is_empty() {
1123 drop(entry);
1124 self.subscriptions_inst_id.remove(&arg.channel);
1125 }
1126 }
1127 }
1128 }
1129 }
1130
1131 Ok(())
1132 }
1133
1134 async fn subscribe_inst_id(
1135 &self,
1136 channel: OKXWsChannel,
1137 inst_id: Ustr,
1138 ) -> Result<(), OKXWsError> {
1139 self.subscribe(vec![OKXSubscriptionArg {
1140 channel,
1141 inst_type: None,
1142 inst_family: None,
1143 inst_id: Some(inst_id),
1144 }])
1145 .await
1146 }
1147
1148 async fn unsubscribe_inst_id(
1149 &self,
1150 channel: OKXWsChannel,
1151 inst_id: Ustr,
1152 ) -> Result<(), OKXWsError> {
1153 self.unsubscribe(vec![OKXSubscriptionArg {
1154 channel,
1155 inst_type: None,
1156 inst_family: None,
1157 inst_id: Some(inst_id),
1158 }])
1159 .await
1160 }
1161
1162 pub async fn unsubscribe_all(&self) -> Result<(), OKXWsError> {
1171 const BATCH_SIZE: usize = 256;
1172
1173 let mut all_args = Vec::new();
1174
1175 for entry in self.subscriptions_inst_type.iter() {
1176 let (channel, inst_types) = entry.pair();
1177 for inst_type in inst_types {
1178 all_args.push(OKXSubscriptionArg {
1179 channel: channel.clone(),
1180 inst_type: Some(*inst_type),
1181 inst_family: None,
1182 inst_id: None,
1183 });
1184 }
1185 }
1186
1187 for entry in self.subscriptions_inst_family.iter() {
1188 let (channel, inst_families) = entry.pair();
1189 for inst_family in inst_families {
1190 all_args.push(OKXSubscriptionArg {
1191 channel: channel.clone(),
1192 inst_type: None,
1193 inst_family: Some(*inst_family),
1194 inst_id: None,
1195 });
1196 }
1197 }
1198
1199 for entry in self.subscriptions_inst_id.iter() {
1200 let (channel, inst_ids) = entry.pair();
1201 for inst_id in inst_ids {
1202 all_args.push(OKXSubscriptionArg {
1203 channel: channel.clone(),
1204 inst_type: None,
1205 inst_family: None,
1206 inst_id: Some(*inst_id),
1207 });
1208 }
1209 }
1210
1211 for entry in self.subscriptions_bare.iter() {
1212 let channel = entry.key();
1213 all_args.push(OKXSubscriptionArg {
1214 channel: channel.clone(),
1215 inst_type: None,
1216 inst_family: None,
1217 inst_id: None,
1218 });
1219 }
1220
1221 if all_args.is_empty() {
1222 log::debug!("No active subscriptions to unsubscribe from");
1223 return Ok(());
1224 }
1225
1226 log::debug!("Batched unsubscribe from {} channels", all_args.len());
1227
1228 for chunk in all_args.chunks(BATCH_SIZE) {
1229 self.unsubscribe(chunk.to_vec()).await?;
1230 }
1231
1232 self.index_pair_subscribers.clear();
1236
1237 Ok(())
1238 }
1239
1240 pub async fn subscribe_instruments(
1252 &self,
1253 instrument_type: OKXInstrumentType,
1254 ) -> Result<(), OKXWsError> {
1255 let arg = OKXSubscriptionArg {
1256 channel: OKXWsChannel::Instruments,
1257 inst_type: Some(instrument_type),
1258 inst_family: None,
1259 inst_id: None,
1260 };
1261 self.subscribe(vec![arg]).await
1262 }
1263
1264 pub async fn subscribe_instrument(
1278 &self,
1279 instrument_id: InstrumentId,
1280 ) -> Result<(), OKXWsError> {
1281 let inst_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
1282 log::debug!("Subscribing to instrument type {inst_type:?} for {instrument_id}");
1283 self.subscribe_instruments(inst_type).await
1284 }
1285
1286 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1295 self.subscribe_book_with_depth(instrument_id, 0).await
1296 }
1297
1298 pub(crate) async fn subscribe_books_channel(
1300 &self,
1301 instrument_id: InstrumentId,
1302 ) -> Result<(), OKXWsError> {
1303 self.subscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1304 .await
1305 }
1306
1307 pub async fn subscribe_book_depth5(
1319 &self,
1320 instrument_id: InstrumentId,
1321 ) -> Result<(), OKXWsError> {
1322 self.subscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1323 .await
1324 }
1325
1326 pub async fn subscribe_book50_l2_tbt(
1338 &self,
1339 instrument_id: InstrumentId,
1340 ) -> Result<(), OKXWsError> {
1341 self.subscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1342 .await
1343 }
1344
1345 pub async fn subscribe_book_l2_tbt(
1357 &self,
1358 instrument_id: InstrumentId,
1359 ) -> Result<(), OKXWsError> {
1360 self.subscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1361 .await
1362 }
1363
1364 pub async fn subscribe_book_with_depth(
1378 &self,
1379 instrument_id: InstrumentId,
1380 depth: u16,
1381 ) -> anyhow::Result<()> {
1382 let vip = self.vip_level();
1383
1384 match depth {
1385 50 => {
1386 if vip < OKXVipLevel::Vip4 {
1387 anyhow::bail!(
1388 "VIP level {vip} insufficient for 50 depth subscription (requires VIP4)"
1389 );
1390 }
1391 self.subscribe_book50_l2_tbt(instrument_id)
1392 .await
1393 .map_err(|e| anyhow::anyhow!(e))
1394 }
1395 0 | 400 => {
1396 if vip >= OKXVipLevel::Vip5 {
1397 self.subscribe_book_l2_tbt(instrument_id)
1398 .await
1399 .map_err(|e| anyhow::anyhow!(e))
1400 } else {
1401 self.subscribe_books_channel(instrument_id)
1402 .await
1403 .map_err(|e| anyhow::anyhow!(e))
1404 }
1405 }
1406 _ => anyhow::bail!("Invalid depth {depth}, must be 0, 50, or 400"),
1407 }
1408 }
1409
1410 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1423 self.subscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1424 .await
1425 }
1426
1427 pub async fn subscribe_trades(
1441 &self,
1442 instrument_id: InstrumentId,
1443 aggregated: bool,
1444 ) -> Result<(), OKXWsError> {
1445 let channel = if aggregated {
1446 OKXWsChannel::TradesAll
1447 } else {
1448 OKXWsChannel::Trades
1449 };
1450 self.subscribe_inst_id(channel, instrument_id.symbol.inner())
1451 .await
1452 }
1453
1454 pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1466 self.subscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1467 .await
1468 }
1469
1470 pub async fn subscribe_mark_prices(
1482 &self,
1483 instrument_id: InstrumentId,
1484 ) -> Result<(), OKXWsError> {
1485 self.subscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1486 .await
1487 }
1488
1489 pub async fn subscribe_index_prices(
1501 &self,
1502 instrument_id: InstrumentId,
1503 ) -> Result<(), OKXWsError> {
1504 let symbol = instrument_id.symbol.inner();
1506 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1507 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1508 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1509
1510 let _guard = self.index_pair_transition.lock().await;
1516
1517 let is_first = {
1522 let mut count = self.index_pair_subscribers.entry(base_pair).or_insert(0);
1523 *count += 1;
1524 *count == 1
1525 };
1526
1527 if !is_first {
1528 return Ok(());
1529 }
1530
1531 let arg = OKXSubscriptionArg {
1532 channel: OKXWsChannel::IndexTickers,
1533 inst_type: None,
1534 inst_family: None,
1535 inst_id: Some(base_pair),
1536 };
1537
1538 match self.subscribe(vec![arg]).await {
1539 Ok(()) => Ok(()),
1540 Err(e) => {
1541 self.index_pair_subscribers.remove(&base_pair);
1550 Err(e)
1551 }
1552 }
1553 }
1554
1555 pub async fn subscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1568 let arg = OKXSubscriptionArg {
1569 channel: OKXWsChannel::OptionSummary,
1570 inst_type: None,
1571 inst_family: Some(inst_family),
1572 inst_id: None,
1573 };
1574 self.subscribe(vec![arg]).await
1575 }
1576
1577 pub async fn subscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1587 let arg = OKXSubscriptionArg {
1588 channel: OKXWsChannel::EventContractMarkets,
1589 inst_type: Some(OKXInstrumentType::Events),
1590 inst_family: None,
1591 inst_id: None,
1592 };
1593 self.subscribe(vec![arg]).await
1594 }
1595
1596 pub fn option_greeks_subs(&self) -> &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>> {
1600 &self.option_greeks_subs
1601 }
1602
1603 pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1606 let both: AHashSet<OKXGreeksType> =
1607 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
1608 self.option_greeks_subs.insert(instrument_id, both);
1609 }
1610
1611 pub fn add_option_greeks_sub_with_conventions(
1614 &self,
1615 instrument_id: InstrumentId,
1616 conventions: AHashSet<OKXGreeksType>,
1617 ) {
1618 let set = if conventions.is_empty() {
1619 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
1620 } else {
1621 conventions
1622 };
1623 self.option_greeks_subs.insert(instrument_id, set);
1624 }
1625
1626 pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1628 self.option_greeks_subs.remove(instrument_id);
1629 }
1630
1631 pub async fn subscribe_funding_rates(
1643 &self,
1644 instrument_id: InstrumentId,
1645 ) -> Result<(), OKXWsError> {
1646 self.subscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1647 .await
1648 }
1649
1650 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
1662 let channel = bar_spec_as_okx_channel(bar_type.spec())
1664 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1665 self.subscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
1666 .await
1667 }
1668
1669 pub async fn unsubscribe_instruments(
1675 &self,
1676 instrument_type: OKXInstrumentType,
1677 ) -> Result<(), OKXWsError> {
1678 let arg = OKXSubscriptionArg {
1679 channel: OKXWsChannel::Instruments,
1680 inst_type: Some(instrument_type),
1681 inst_family: None,
1682 inst_id: None,
1683 };
1684 self.unsubscribe(vec![arg]).await
1685 }
1686
1687 pub async fn unsubscribe_instrument(
1697 &self,
1698 instrument_id: InstrumentId,
1699 ) -> Result<(), OKXWsError> {
1700 log::debug!("Instrument unsubscribe is a no-op (shared per-type channel): {instrument_id}");
1701 Ok(())
1702 }
1703
1704 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1710 self.unsubscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1711 .await
1712 }
1713
1714 pub async fn unsubscribe_book_depth5(
1720 &self,
1721 instrument_id: InstrumentId,
1722 ) -> Result<(), OKXWsError> {
1723 self.unsubscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1724 .await
1725 }
1726
1727 pub async fn unsubscribe_book50_l2_tbt(
1733 &self,
1734 instrument_id: InstrumentId,
1735 ) -> Result<(), OKXWsError> {
1736 self.unsubscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1737 .await
1738 }
1739
1740 pub async fn unsubscribe_book_l2_tbt(
1746 &self,
1747 instrument_id: InstrumentId,
1748 ) -> Result<(), OKXWsError> {
1749 self.unsubscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1750 .await
1751 }
1752
1753 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1759 self.unsubscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1760 .await
1761 }
1762
1763 pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1769 self.unsubscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1770 .await
1771 }
1772
1773 pub async fn unsubscribe_mark_prices(
1779 &self,
1780 instrument_id: InstrumentId,
1781 ) -> Result<(), OKXWsError> {
1782 self.unsubscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1783 .await
1784 }
1785
1786 pub async fn unsubscribe_index_prices(
1799 &self,
1800 instrument_id: InstrumentId,
1801 ) -> Result<(), OKXWsError> {
1802 let symbol = instrument_id.symbol.inner();
1803 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1804 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1805 let base_pair = Ustr::from(&format!("{base}-{quote}"));
1806
1807 let _guard = self.index_pair_transition.lock().await;
1810
1811 let is_last = {
1812 let Some(mut count) = self.index_pair_subscribers.get_mut(&base_pair) else {
1813 return Ok(());
1815 };
1816 *count = count.saturating_sub(1);
1817 *count == 0
1818 };
1819
1820 if !is_last {
1821 return Ok(());
1822 }
1823
1824 self.index_pair_subscribers
1825 .remove_if(&base_pair, |_, count| *count == 0);
1826
1827 let arg = OKXSubscriptionArg {
1828 channel: OKXWsChannel::IndexTickers,
1829 inst_type: None,
1830 inst_family: None,
1831 inst_id: Some(base_pair),
1832 };
1833 self.unsubscribe(vec![arg]).await
1834 }
1835
1836 pub async fn unsubscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1842 let arg = OKXSubscriptionArg {
1843 channel: OKXWsChannel::OptionSummary,
1844 inst_type: None,
1845 inst_family: Some(inst_family),
1846 inst_id: None,
1847 };
1848 self.unsubscribe(vec![arg]).await
1849 }
1850
1851 pub async fn unsubscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1857 let arg = OKXSubscriptionArg {
1858 channel: OKXWsChannel::EventContractMarkets,
1859 inst_type: Some(OKXInstrumentType::Events),
1860 inst_family: None,
1861 inst_id: None,
1862 };
1863 self.unsubscribe(vec![arg]).await
1864 }
1865
1866 pub async fn unsubscribe_funding_rates(
1872 &self,
1873 instrument_id: InstrumentId,
1874 ) -> Result<(), OKXWsError> {
1875 self.unsubscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1876 .await
1877 }
1878
1879 pub async fn unsubscribe_trades(
1885 &self,
1886 instrument_id: InstrumentId,
1887 aggregated: bool,
1888 ) -> Result<(), OKXWsError> {
1889 let channel = if aggregated {
1890 OKXWsChannel::TradesAll
1891 } else {
1892 OKXWsChannel::Trades
1893 };
1894 self.unsubscribe_inst_id(channel, instrument_id.symbol.inner())
1895 .await
1896 }
1897
1898 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
1904 let channel = bar_spec_as_okx_channel(bar_type.spec())
1905 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1906 self.unsubscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
1907 .await
1908 }
1909
1910 pub async fn subscribe_orders(
1916 &self,
1917 instrument_type: OKXInstrumentType,
1918 ) -> Result<(), OKXWsError> {
1919 let arg = OKXSubscriptionArg {
1920 channel: OKXWsChannel::Orders,
1921 inst_type: Some(instrument_type),
1922 inst_family: None,
1923 inst_id: None,
1924 };
1925 self.subscribe(vec![arg]).await
1926 }
1927
1928 pub async fn unsubscribe_orders(
1934 &self,
1935 instrument_type: OKXInstrumentType,
1936 ) -> Result<(), OKXWsError> {
1937 let arg = OKXSubscriptionArg {
1938 channel: OKXWsChannel::Orders,
1939 inst_type: Some(instrument_type),
1940 inst_family: None,
1941 inst_id: None,
1942 };
1943 self.unsubscribe(vec![arg]).await
1944 }
1945
1946 pub async fn subscribe_spread_orders(&self) -> Result<(), OKXWsError> {
1952 let arg = OKXSubscriptionArg {
1953 channel: OKXWsChannel::SprdOrders,
1954 inst_type: None,
1955 inst_family: None,
1956 inst_id: None,
1957 };
1958 self.subscribe(vec![arg]).await
1959 }
1960
1961 pub async fn unsubscribe_spread_orders(&self) -> Result<(), OKXWsError> {
1967 let arg = OKXSubscriptionArg {
1968 channel: OKXWsChannel::SprdOrders,
1969 inst_type: None,
1970 inst_family: None,
1971 inst_id: None,
1972 };
1973 self.unsubscribe(vec![arg]).await
1974 }
1975
1976 pub async fn subscribe_spread_quotes(
1982 &self,
1983 instrument_id: InstrumentId,
1984 ) -> Result<(), OKXWsError> {
1985 self.subscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
1986 .await
1987 }
1988
1989 pub async fn subscribe_spread_book(
1995 &self,
1996 instrument_id: InstrumentId,
1997 ) -> Result<(), OKXWsError> {
1998 self.subscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
1999 .await
2000 }
2001
2002 pub async fn subscribe_spread_trades(
2008 &self,
2009 instrument_id: InstrumentId,
2010 ) -> Result<(), OKXWsError> {
2011 self.subscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2012 .await
2013 }
2014
2015 pub async fn unsubscribe_spread_quotes(
2021 &self,
2022 instrument_id: InstrumentId,
2023 ) -> Result<(), OKXWsError> {
2024 self.unsubscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2025 .await
2026 }
2027
2028 pub async fn unsubscribe_spread_book(
2034 &self,
2035 instrument_id: InstrumentId,
2036 ) -> Result<(), OKXWsError> {
2037 self.unsubscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2038 .await
2039 }
2040
2041 pub async fn unsubscribe_spread_trades(
2047 &self,
2048 instrument_id: InstrumentId,
2049 ) -> Result<(), OKXWsError> {
2050 self.unsubscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2051 .await
2052 }
2053
2054 pub async fn subscribe_orders_algo(
2060 &self,
2061 instrument_type: OKXInstrumentType,
2062 ) -> Result<(), OKXWsError> {
2063 let arg = OKXSubscriptionArg {
2064 channel: OKXWsChannel::OrdersAlgo,
2065 inst_type: Some(instrument_type),
2066 inst_family: None,
2067 inst_id: None,
2068 };
2069 self.subscribe(vec![arg]).await
2070 }
2071
2072 pub async fn unsubscribe_orders_algo(
2078 &self,
2079 instrument_type: OKXInstrumentType,
2080 ) -> Result<(), OKXWsError> {
2081 let arg = OKXSubscriptionArg {
2082 channel: OKXWsChannel::OrdersAlgo,
2083 inst_type: Some(instrument_type),
2084 inst_family: None,
2085 inst_id: None,
2086 };
2087 self.unsubscribe(vec![arg]).await
2088 }
2089
2090 pub async fn subscribe_algo_advance(
2096 &self,
2097 instrument_type: OKXInstrumentType,
2098 ) -> Result<(), OKXWsError> {
2099 let arg = OKXSubscriptionArg {
2100 channel: OKXWsChannel::AlgoAdvance,
2101 inst_type: Some(instrument_type),
2102 inst_family: None,
2103 inst_id: None,
2104 };
2105 self.subscribe(vec![arg]).await
2106 }
2107
2108 pub async fn unsubscribe_algo_advance(
2114 &self,
2115 instrument_type: OKXInstrumentType,
2116 ) -> Result<(), OKXWsError> {
2117 let arg = OKXSubscriptionArg {
2118 channel: OKXWsChannel::AlgoAdvance,
2119 inst_type: Some(instrument_type),
2120 inst_family: None,
2121 inst_id: None,
2122 };
2123 self.unsubscribe(vec![arg]).await
2124 }
2125
2126 pub async fn subscribe_fills(
2132 &self,
2133 instrument_type: OKXInstrumentType,
2134 ) -> Result<(), OKXWsError> {
2135 let arg = OKXSubscriptionArg {
2136 channel: OKXWsChannel::Fills,
2137 inst_type: Some(instrument_type),
2138 inst_family: None,
2139 inst_id: None,
2140 };
2141 self.subscribe(vec![arg]).await
2142 }
2143
2144 pub async fn unsubscribe_fills(
2150 &self,
2151 instrument_type: OKXInstrumentType,
2152 ) -> Result<(), OKXWsError> {
2153 let arg = OKXSubscriptionArg {
2154 channel: OKXWsChannel::Fills,
2155 inst_type: Some(instrument_type),
2156 inst_family: None,
2157 inst_id: None,
2158 };
2159 self.unsubscribe(vec![arg]).await
2160 }
2161
2162 pub async fn subscribe_account(&self) -> Result<(), OKXWsError> {
2168 let arg = OKXSubscriptionArg {
2169 channel: OKXWsChannel::Account,
2170 inst_type: None,
2171 inst_family: None,
2172 inst_id: None,
2173 };
2174 self.subscribe(vec![arg]).await
2175 }
2176
2177 pub async fn unsubscribe_account(&self) -> Result<(), OKXWsError> {
2183 let arg = OKXSubscriptionArg {
2184 channel: OKXWsChannel::Account,
2185 inst_type: None,
2186 inst_family: None,
2187 inst_id: None,
2188 };
2189 self.unsubscribe(vec![arg]).await
2190 }
2191
2192 pub async fn subscribe_positions(
2202 &self,
2203 inst_type: OKXInstrumentType,
2204 ) -> Result<(), OKXWsError> {
2205 let arg = OKXSubscriptionArg {
2206 channel: OKXWsChannel::Positions,
2207 inst_type: Some(inst_type),
2208 inst_family: None,
2209 inst_id: None,
2210 };
2211 self.subscribe(vec![arg]).await
2212 }
2213
2214 pub async fn unsubscribe_positions(
2220 &self,
2221 inst_type: OKXInstrumentType,
2222 ) -> Result<(), OKXWsError> {
2223 let arg = OKXSubscriptionArg {
2224 channel: OKXWsChannel::Positions,
2225 inst_type: Some(inst_type),
2226 inst_family: None,
2227 inst_id: None,
2228 };
2229 self.unsubscribe(vec![arg]).await
2230 }
2231
2232 async fn ws_batch_place_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
2238 let request_id = self.generate_unique_request_id();
2239 let request = OKXWsRequest::<Value> {
2240 id: Some(request_id.clone()),
2241 op: super::enums::OKXWsOperation::BatchOrders,
2242 exp_time: None,
2243 args,
2244 };
2245
2246 let payload = serde_json::to_string(&request)
2247 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch orders: {e}")))?;
2248
2249 let cmd = HandlerCommand::Send {
2250 payload,
2251 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_ORDER.to_vec()),
2252 request_id: Some(request_id),
2253 client_order_id: None,
2254 op: Some(super::enums::OKXWsOperation::BatchOrders),
2255 };
2256
2257 self.send_cmd(cmd).await
2258 }
2259
2260 async fn ws_batch_cancel_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
2266 let request_id = self.generate_unique_request_id();
2267 let request = OKXWsRequest::<Value> {
2268 id: Some(request_id.clone()),
2269 op: super::enums::OKXWsOperation::BatchCancelOrders,
2270 exp_time: None,
2271 args,
2272 };
2273
2274 let payload = serde_json::to_string(&request)
2275 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch cancel: {e}")))?;
2276
2277 let cmd = HandlerCommand::Send {
2278 payload,
2279 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_CANCEL.to_vec()),
2280 request_id: Some(request_id),
2281 client_order_id: None,
2282 op: Some(super::enums::OKXWsOperation::BatchCancelOrders),
2283 };
2284
2285 self.send_cmd(cmd).await
2286 }
2287
2288 async fn ws_batch_amend_orders(&self, args: Vec<Value>) -> Result<(), OKXWsError> {
2294 let request_id = self.generate_unique_request_id();
2295 let request = OKXWsRequest::<Value> {
2296 id: Some(request_id.clone()),
2297 op: super::enums::OKXWsOperation::BatchAmendOrders,
2298 exp_time: None,
2299 args,
2300 };
2301
2302 let payload = serde_json::to_string(&request)
2303 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch amend: {e}")))?;
2304
2305 let cmd = HandlerCommand::Send {
2306 payload,
2307 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_AMEND.to_vec()),
2308 request_id: Some(request_id),
2309 client_order_id: None,
2310 op: Some(super::enums::OKXWsOperation::BatchAmendOrders),
2311 };
2312
2313 self.send_cmd(cmd).await
2314 }
2315
2316 #[expect(clippy::too_many_arguments)]
2328 pub async fn submit_order(
2329 &self,
2330 trader_id: TraderId,
2331 strategy_id: StrategyId,
2332 instrument_id: InstrumentId,
2333 td_mode: OKXTradeMode,
2334 client_order_id: ClientOrderId,
2335 order_side: OrderSide,
2336 order_type: OrderType,
2337 quantity: Quantity,
2338 time_in_force: Option<TimeInForce>,
2339 price: Option<Price>,
2340 trigger_price: Option<Price>,
2341 post_only: Option<bool>,
2342 reduce_only: Option<bool>,
2343 quote_quantity: Option<bool>,
2344 position_side: Option<PositionSide>,
2345 attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
2346 px_usd: Option<String>,
2347 px_vol: Option<String>,
2348 speed_bump: Option<String>,
2349 outcome: Option<String>,
2350 slippage_pct: Option<String>,
2351 ) -> Result<(), OKXWsError> {
2352 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
2353 return Err(OKXWsError::ClientError(format!(
2354 "Unsupported order type: {order_type:?}",
2355 )));
2356 }
2357
2358 if let Some(tif) = time_in_force
2359 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
2360 {
2361 return Err(OKXWsError::ClientError(format!(
2362 "Unsupported time in force: {tif:?}",
2363 )));
2364 }
2365
2366 let mut builder = WsPostOrderParamsBuilder::default();
2367
2368 let inst_id_code = self
2369 .get_inst_id_code(&instrument_id.symbol.inner())
2370 .ok_or_else(|| {
2371 OKXWsError::ClientError(format!(
2372 "No instIdCode cached for {instrument_id}, cannot submit order"
2373 ))
2374 })?;
2375 builder.inst_id_code(inst_id_code);
2376
2377 builder.td_mode(td_mode);
2378 builder.cl_ord_id(client_order_id.as_str());
2379
2380 let (instrument_type, quote_currency) = {
2381 let instruments = self.instruments_cache.load();
2382 let symbol = instrument_id.symbol.inner();
2383 let instrument = instruments.get(&symbol).ok_or_else(|| {
2384 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2385 })?;
2386 let instrument_type = okx_instrument_type(instrument)
2387 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2388 (instrument_type, instrument.quote_currency())
2389 };
2390
2391 if instrument_type == OKXInstrumentType::Option
2393 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
2394 {
2395 return Err(OKXWsError::ClientError(
2396 "Market orders are not supported for OKX options, use Limit orders instead"
2397 .to_string(),
2398 ));
2399 }
2400
2401 match instrument_type {
2402 OKXInstrumentType::Spot => {
2403 builder.ccy(quote_currency.to_string());
2405 }
2406 OKXInstrumentType::Margin => {
2407 builder.ccy(quote_currency.to_string());
2408
2409 if let Some(ro) = reduce_only
2410 && ro
2411 {
2412 builder.reduce_only(ro);
2413 }
2414 }
2415 OKXInstrumentType::Swap | OKXInstrumentType::Futures => {
2416 builder.ccy(quote_currency.to_string());
2418
2419 if position_side.is_none() {
2422 builder.pos_side(OKXPositionSide::Net);
2423 }
2424 }
2425 OKXInstrumentType::Option => {
2426 builder.ccy(quote_currency.to_string());
2427
2428 if position_side.is_none() {
2429 builder.pos_side(OKXPositionSide::Net);
2430 }
2431 }
2433 OKXInstrumentType::Events => {}
2434 _ => {
2435 builder.ccy(quote_currency.to_string());
2436
2437 if position_side.is_none() {
2438 builder.pos_side(OKXPositionSide::Net);
2439 }
2440
2441 if let Some(ro) = reduce_only
2442 && ro
2443 {
2444 builder.reduce_only(ro);
2445 }
2446 }
2447 }
2448
2449 if let Some(attach_algo_ords) = attach_algo_ords {
2450 builder.attach_algo_ords(attach_algo_ords);
2451 }
2452
2453 if instrument_type == OKXInstrumentType::Spot
2460 && order_type == OrderType::Market
2461 && td_mode == OKXTradeMode::Cash
2462 {
2463 match quote_quantity {
2464 Some(true) => {
2465 builder.tgt_ccy(OKXTargetCurrency::QuoteCcy);
2466 }
2467 Some(false) if order_side == OrderSide::Buy => {
2469 builder.tgt_ccy(OKXTargetCurrency::BaseCcy);
2470 }
2471 Some(false) | None => {}
2473 }
2474 }
2475
2476 builder.side(order_side.as_specified());
2477
2478 if let Some(pos_side) = position_side {
2479 builder.pos_side(pos_side);
2480 }
2481
2482 let (okx_ord_type, price) = if post_only.unwrap_or(false) {
2486 (OKXOrderType::PostOnly, price)
2487 } else if let Some(tif) = time_in_force {
2488 match (order_type, tif) {
2489 (OrderType::Market, TimeInForce::Fok) => {
2490 return Err(OKXWsError::ClientError(
2491 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string()
2492 ));
2493 }
2494 (OrderType::Market, TimeInForce::Ioc) => {
2495 if matches!(
2497 instrument_type,
2498 OKXInstrumentType::Spot | OKXInstrumentType::Option
2499 ) {
2500 (OKXOrderType::Market, price)
2501 } else {
2502 (OKXOrderType::OptimalLimitIoc, price)
2503 }
2504 }
2505 (OrderType::Limit, TimeInForce::Fok) => {
2506 if instrument_type == OKXInstrumentType::Option {
2508 (OKXOrderType::OpFok, price)
2509 } else {
2510 (OKXOrderType::Fok, price)
2511 }
2512 }
2513 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
2514 _ => (OKXOrderType::from(order_type), price),
2515 }
2516 } else {
2517 (OKXOrderType::from(order_type), price)
2518 };
2519
2520 log::debug!(
2521 "Order type mapping: order_type={order_type:?}, time_in_force={time_in_force:?}, post_only={post_only:?} -> okx_ord_type={okx_ord_type:?}"
2522 );
2523
2524 let speed_bump = if instrument_type == OKXInstrumentType::Events {
2525 if outcome.is_none() {
2526 return Err(OKXWsError::ClientError(
2527 "OKX event contract orders require `outcome`".to_string(),
2528 ));
2529 }
2530
2531 if okx_ord_type == OKXOrderType::PostOnly {
2532 speed_bump
2533 } else {
2534 Some(speed_bump.unwrap_or_else(|| "1".to_string()))
2535 }
2536 } else {
2537 speed_bump
2538 };
2539
2540 if let Some(speed_bump) = speed_bump {
2541 builder.speed_bump(speed_bump);
2542 }
2543
2544 if let Some(outcome) = outcome {
2545 builder.outcome(outcome);
2546 }
2547
2548 if let Some(slippage) = slippage_pct {
2549 builder.slippage_pct(slippage);
2550 }
2551
2552 builder.ord_type(okx_ord_type);
2553 builder.sz(quantity.to_string());
2554
2555 if let Some(usd) = px_usd {
2557 builder.px_usd(usd);
2558 } else if let Some(vol) = px_vol {
2559 builder.px_vol(vol);
2560 } else if let Some(tp) = trigger_price {
2561 builder.px(tp.to_string());
2562 } else if let Some(p) = price {
2563 builder.px(p.to_string());
2564 }
2565
2566 builder.tag(OKX_NAUTILUS_BROKER_ID);
2567
2568 let params = builder
2569 .build()
2570 .map_err(|e| OKXWsError::ClientError(format!("Build order params error: {e}")))?;
2571
2572 let request_id = self.generate_unique_request_id();
2573 let request = OKXWsRequest {
2574 id: Some(request_id.clone()),
2575 op: super::enums::OKXWsOperation::Order,
2576 exp_time: None,
2577 args: vec![params],
2578 };
2579
2580 let payload = serde_json::to_string(&request)
2581 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize order: {e}")))?;
2582
2583 let cl_ord_key = client_order_id.to_string();
2584 self.pending_orders.insert(
2585 cl_ord_key.clone(),
2586 PendingOrderInfo {
2587 trader_id,
2588 strategy_id,
2589 instrument_id,
2590 },
2591 );
2592
2593 let cmd = HandlerCommand::Send {
2594 payload,
2595 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ORDER.to_vec()),
2596 request_id: Some(request_id),
2597 client_order_id: Some(client_order_id),
2598 op: Some(super::enums::OKXWsOperation::Order),
2599 };
2600
2601 let result = self.send_cmd(cmd).await;
2602
2603 if result.is_err() {
2604 self.pending_orders.remove(&cl_ord_key);
2605 }
2606
2607 result
2608 }
2609
2610 #[expect(clippy::too_many_arguments)]
2626 pub async fn modify_order(
2627 &self,
2628 trader_id: TraderId,
2629 strategy_id: StrategyId,
2630 instrument_id: InstrumentId,
2631 client_order_id: Option<ClientOrderId>,
2632 price: Option<Price>,
2633 quantity: Option<Quantity>,
2634 venue_order_id: Option<VenueOrderId>,
2635 new_px_usd: Option<String>,
2636 new_px_vol: Option<String>,
2637 speed_bump: Option<String>,
2638 ) -> Result<(), OKXWsError> {
2639 let mut builder = WsAmendOrderParamsBuilder::default();
2640
2641 let inst_id_code = self
2642 .get_inst_id_code(&instrument_id.symbol.inner())
2643 .ok_or_else(|| {
2644 OKXWsError::ClientError(format!(
2645 "No instIdCode cached for {instrument_id}, cannot amend order"
2646 ))
2647 })?;
2648 builder.inst_id_code(inst_id_code);
2649
2650 if let Some(venue_order_id) = venue_order_id {
2651 builder.ord_id(venue_order_id.as_str());
2652 }
2653
2654 let cl_ord_key = client_order_id.map(|id| id.to_string());
2655
2656 if let Some(client_order_id) = client_order_id {
2657 builder.cl_ord_id(client_order_id.as_str());
2658 self.pending_amends.insert(
2659 client_order_id.to_string(),
2660 PendingOrderInfo {
2661 trader_id,
2662 strategy_id,
2663 instrument_id,
2664 },
2665 );
2666 }
2667
2668 if let Some(usd) = new_px_usd {
2670 builder.new_px_usd(usd);
2671 } else if let Some(vol) = new_px_vol {
2672 builder.new_px_vol(vol);
2673 } else if let Some(price) = price {
2674 builder.new_px(price.to_string());
2675 }
2676
2677 if let Some(quantity) = quantity {
2678 builder.new_sz(quantity.to_string());
2679 }
2680
2681 if let Some(speed_bump) = speed_bump {
2682 builder.speed_bump(speed_bump);
2683 }
2684
2685 let params = builder
2686 .build()
2687 .map_err(|e| OKXWsError::ClientError(format!("Build amend params error: {e}")))?;
2688
2689 let request_id = self.generate_unique_request_id();
2690 let request = OKXWsRequest {
2691 id: Some(request_id.clone()),
2692 op: super::enums::OKXWsOperation::AmendOrder,
2693 exp_time: None,
2694 args: vec![params],
2695 };
2696
2697 let payload = serde_json::to_string(&request)
2698 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize amend: {e}")))?;
2699
2700 let cmd = HandlerCommand::Send {
2701 payload,
2702 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_AMEND.to_vec()),
2703 request_id: Some(request_id),
2704 client_order_id,
2705 op: Some(super::enums::OKXWsOperation::AmendOrder),
2706 };
2707
2708 let result = self.send_cmd(cmd).await;
2709
2710 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2711 self.pending_amends.remove(key);
2712 }
2713
2714 result
2715 }
2716
2717 pub async fn cancel_order(
2728 &self,
2729 trader_id: TraderId,
2730 strategy_id: StrategyId,
2731 instrument_id: InstrumentId,
2732 client_order_id: Option<ClientOrderId>,
2733 venue_order_id: Option<VenueOrderId>,
2734 ) -> Result<(), OKXWsError> {
2735 let mut builder = WsCancelOrderParamsBuilder::default();
2736
2737 let inst_id_code = self
2738 .get_inst_id_code(&instrument_id.symbol.inner())
2739 .ok_or_else(|| {
2740 OKXWsError::ClientError(format!(
2741 "No instIdCode cached for {instrument_id}, cannot cancel order"
2742 ))
2743 })?;
2744 builder.inst_id_code(inst_id_code);
2745
2746 if let Some(venue_order_id) = venue_order_id {
2747 builder.ord_id(venue_order_id.as_str());
2748 }
2749
2750 let cl_ord_key = client_order_id.map(|id| id.to_string());
2751
2752 if let Some(client_order_id) = client_order_id {
2753 builder.cl_ord_id(client_order_id.as_str());
2754 self.pending_cancels.insert(
2755 client_order_id.to_string(),
2756 PendingOrderInfo {
2757 trader_id,
2758 strategy_id,
2759 instrument_id,
2760 },
2761 );
2762 }
2763
2764 let params = builder
2765 .build()
2766 .map_err(|e| OKXWsError::ClientError(format!("Build cancel params error: {e}")))?;
2767
2768 let request_id = self.generate_unique_request_id();
2769 let request = OKXWsRequest {
2770 id: Some(request_id.clone()),
2771 op: super::enums::OKXWsOperation::CancelOrder,
2772 exp_time: None,
2773 args: vec![params],
2774 };
2775
2776 let payload = serde_json::to_string(&request)
2777 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel: {e}")))?;
2778
2779 let cmd = HandlerCommand::Send {
2780 payload,
2781 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_CANCEL.to_vec()),
2782 request_id: Some(request_id),
2783 client_order_id,
2784 op: Some(super::enums::OKXWsOperation::CancelOrder),
2785 };
2786
2787 let result = self.send_cmd(cmd).await;
2788
2789 if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2790 self.pending_cancels.remove(key);
2791 }
2792
2793 result
2794 }
2795
2796 pub async fn mass_cancel_orders(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
2806 let (inst_type, inst_family) = {
2807 let instrument = self
2808 .instruments_cache
2809 .get_cloned(&instrument_id.symbol.inner())
2810 .ok_or_else(|| {
2811 OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2812 })?;
2813
2814 let inst_type = okx_instrument_type(&instrument)
2815 .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2816
2817 let symbol = instrument.symbol().inner();
2818 let inst_family = match &instrument {
2819 InstrumentAny::CurrencyPair(_) => symbol.as_str().to_string(),
2820 InstrumentAny::CryptoPerpetual(_) => symbol
2821 .as_str()
2822 .strip_suffix("-SWAP")
2823 .unwrap_or(symbol.as_str())
2824 .to_string(),
2825 InstrumentAny::CryptoFuture(_) => {
2826 let s = symbol.as_str();
2827 if let Some(idx) = s.rfind('-') {
2828 s[..idx].to_string()
2829 } else {
2830 s.to_string()
2831 }
2832 }
2833 _ => {
2834 return Err(OKXWsError::ClientError(
2835 "Unsupported instrument type for mass cancel".to_string(),
2836 ));
2837 }
2838 };
2839
2840 (inst_type, inst_family)
2841 };
2842
2843 let params = WsMassCancelParams {
2844 inst_type,
2845 inst_family: Ustr::from(&inst_family),
2846 };
2847
2848 let request_id = self.generate_unique_request_id();
2849 let request = OKXWsRequest {
2850 id: Some(request_id.clone()),
2851 op: super::enums::OKXWsOperation::MassCancel,
2852 exp_time: None,
2853 args: vec![
2854 serde_json::to_value(params).map_err(|e| OKXWsError::JsonError(e.to_string()))?,
2855 ],
2856 };
2857
2858 let payload = serde_json::to_string(&request)
2859 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize mass cancel: {e}")))?;
2860
2861 let cmd = HandlerCommand::Send {
2862 payload,
2863 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_MASS_CANCEL.to_vec()),
2864 request_id: Some(request_id),
2865 client_order_id: None,
2866 op: Some(super::enums::OKXWsOperation::MassCancel),
2867 };
2868
2869 self.send_cmd(cmd).await
2870 }
2871
2872 #[expect(clippy::type_complexity)]
2879 pub async fn batch_submit_orders(
2880 &self,
2881 orders: Vec<(
2882 OKXInstrumentType,
2883 InstrumentId,
2884 OKXTradeMode,
2885 ClientOrderId,
2886 OrderSide,
2887 Option<PositionSide>,
2888 OrderType,
2889 Quantity,
2890 Option<Price>,
2891 Option<Price>,
2892 Option<bool>,
2893 Option<bool>,
2894 Option<String>,
2895 Option<String>,
2896 )>,
2897 ) -> Result<(), OKXWsError> {
2898 let args: Vec<Value> = {
2899 let mut args = Vec::with_capacity(orders.len());
2900 let inst_id_codes = self.inst_id_code_cache.load();
2901 let instruments = self.instruments_cache.load();
2902
2903 for (
2904 inst_type,
2905 inst_id,
2906 td_mode,
2907 cl_ord_id,
2908 ord_side,
2909 pos_side,
2910 ord_type,
2911 qty,
2912 pr,
2913 tp,
2914 post_only,
2915 reduce_only,
2916 speed_bump,
2917 outcome,
2918 ) in orders
2919 {
2920 let mut builder = WsPostOrderParamsBuilder::default();
2921
2922 let (inst_id_symbol, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
2923 &inst_id_codes,
2924 &inst_id,
2925 "submit",
2926 )?;
2927 builder.inst_id_code(inst_id_code);
2928
2929 builder.td_mode(td_mode);
2930 builder.cl_ord_id(cl_ord_id.as_str());
2931 builder.side(ord_side.as_specified());
2932
2933 if inst_type != OKXInstrumentType::Events
2934 && let Some(instrument) = instruments.get(&inst_id_symbol)
2935 {
2936 builder.ccy(instrument.quote_currency().to_string());
2937 }
2938
2939 if let Some(ps) = pos_side {
2940 builder.pos_side(OKXPositionSide::from(ps));
2941 } else if matches!(
2942 inst_type,
2943 OKXInstrumentType::Swap
2944 | OKXInstrumentType::Futures
2945 | OKXInstrumentType::Option
2946 ) {
2947 builder.pos_side(OKXPositionSide::Net);
2948 }
2949
2950 let okx_ord_type = if post_only.unwrap_or(false) {
2951 OKXOrderType::PostOnly
2952 } else {
2953 match ord_type {
2954 OrderType::Market => OKXOrderType::Market,
2955 OrderType::Limit => OKXOrderType::Limit,
2956 OrderType::MarketToLimit => OKXOrderType::Ioc,
2957 _ => {
2958 return Err(OKXWsError::ClientError(format!(
2959 "Unsupported order type for batch submit: {ord_type:?}"
2960 )));
2961 }
2962 }
2963 };
2964
2965 builder.ord_type(okx_ord_type);
2966 builder.sz(qty.to_string());
2967
2968 if let Some(p) = pr {
2969 builder.px(p.to_string());
2970 } else if let Some(p) = tp {
2971 builder.px(p.to_string());
2972 }
2973
2974 if let Some(ro) = reduce_only {
2975 builder.reduce_only(ro);
2976 }
2977
2978 let speed_bump = if inst_type == OKXInstrumentType::Events {
2979 if outcome.is_none() {
2980 return Err(OKXWsError::ClientError(
2981 "OKX event contract orders require `outcome`".to_string(),
2982 ));
2983 }
2984
2985 if okx_ord_type == OKXOrderType::PostOnly {
2986 speed_bump
2987 } else {
2988 Some(speed_bump.unwrap_or_else(|| "1".to_string()))
2989 }
2990 } else {
2991 speed_bump
2992 };
2993
2994 if let Some(speed_bump) = speed_bump {
2995 builder.speed_bump(speed_bump);
2996 }
2997
2998 if let Some(outcome) = outcome {
2999 builder.outcome(outcome);
3000 }
3001
3002 builder.tag(OKX_NAUTILUS_BROKER_ID);
3003
3004 let params = builder.build().map_err(|e| {
3005 OKXWsError::ClientError(format!("Build order params error: {e}"))
3006 })?;
3007 let val = serde_json::to_value(params)
3008 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3009 args.push(val);
3010 }
3011 args
3012 };
3013
3014 self.ws_batch_place_orders(args).await
3015 }
3016
3017 #[expect(clippy::type_complexity)]
3024 pub async fn batch_modify_orders(
3025 &self,
3026 orders: Vec<(
3027 OKXInstrumentType,
3028 InstrumentId,
3029 ClientOrderId,
3030 ClientOrderId,
3031 Option<Price>,
3032 Option<Quantity>,
3033 Option<String>,
3034 )>,
3035 ) -> Result<(), OKXWsError> {
3036 let args: Vec<Value> = {
3037 let mut args = Vec::with_capacity(orders.len());
3038 let inst_id_codes = self.inst_id_code_cache.load();
3039
3040 for (_inst_type, inst_id, cl_ord_id, new_cl_ord_id, pr, sz, speed_bump) in orders {
3041 let mut builder = WsAmendOrderParamsBuilder::default();
3042
3043 let (_, inst_id_code) =
3044 Self::inst_id_symbol_and_code_from_snapshot(&inst_id_codes, &inst_id, "amend")?;
3045 builder.inst_id_code(inst_id_code);
3046
3047 builder.cl_ord_id(cl_ord_id.as_str());
3048 builder.new_cl_ord_id(new_cl_ord_id.as_str());
3049
3050 if let Some(p) = pr {
3051 builder.new_px(p.to_string());
3052 }
3053
3054 if let Some(q) = sz {
3055 builder.new_sz(q.to_string());
3056 }
3057
3058 if let Some(speed_bump) = speed_bump {
3059 builder.speed_bump(speed_bump);
3060 }
3061
3062 let params = builder.build().map_err(|e| {
3063 OKXWsError::ClientError(format!("Build amend batch params error: {e}"))
3064 })?;
3065 let val = serde_json::to_value(params)
3066 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3067 args.push(val);
3068 }
3069 args
3070 };
3071
3072 self.ws_batch_amend_orders(args).await
3073 }
3074
3075 pub async fn batch_cancel_orders(
3088 &self,
3089 orders: Vec<(InstrumentId, Option<ClientOrderId>, Option<VenueOrderId>)>,
3090 ) -> Result<(), OKXWsError> {
3091 let args: Vec<Value> = {
3092 let mut args = Vec::with_capacity(orders.len());
3093 let inst_id_codes = self.inst_id_code_cache.load();
3094
3095 for (inst_id, cl_ord_id, ord_id) in orders {
3096 let mut builder = WsCancelOrderParamsBuilder::default();
3097
3098 let (_, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3099 &inst_id_codes,
3100 &inst_id,
3101 "cancel",
3102 )?;
3103 builder.inst_id_code(inst_id_code);
3104
3105 if let Some(c) = cl_ord_id {
3106 builder.cl_ord_id(c.as_str());
3107 }
3108
3109 if let Some(o) = ord_id {
3110 builder.ord_id(o.as_str());
3111 }
3112
3113 let params = builder.build().map_err(|e| {
3114 OKXWsError::ClientError(format!("Build cancel batch params error: {e}"))
3115 })?;
3116 let val = serde_json::to_value(params)
3117 .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3118 args.push(val);
3119 }
3120 args
3121 };
3122
3123 self.ws_batch_cancel_orders(args).await
3124 }
3125
3126 #[expect(clippy::too_many_arguments)]
3137 pub async fn submit_algo_order(
3138 &self,
3139 _trader_id: TraderId,
3140 _strategy_id: StrategyId,
3141 instrument_id: InstrumentId,
3142 td_mode: OKXTradeMode,
3143 client_order_id: ClientOrderId,
3144 order_side: OrderSide,
3145 order_type: OrderType,
3146 quantity: Quantity,
3147 trigger_price: Option<Price>,
3148 trigger_type: Option<TriggerType>,
3149 limit_price: Option<Price>,
3150 reduce_only: Option<bool>,
3151 callback_ratio: Option<String>,
3152 callback_spread: Option<String>,
3153 activation_price: Option<Price>,
3154 ) -> Result<(), OKXWsError> {
3155 if !is_conditional_order(order_type) {
3156 return Err(OKXWsError::ClientError(format!(
3157 "Order type {order_type:?} is not a conditional order"
3158 )));
3159 }
3160
3161 let mut builder = WsPostAlgoOrderParamsBuilder::default();
3162
3163 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
3164 return Err(OKXWsError::ClientError(
3165 "Invalid order side for OKX".to_string(),
3166 ));
3167 }
3168
3169 let inst_id_code = self
3170 .get_inst_id_code(&instrument_id.symbol.inner())
3171 .ok_or_else(|| {
3172 OKXWsError::ClientError(format!(
3173 "No instIdCode cached for {instrument_id}, cannot submit algo order"
3174 ))
3175 })?;
3176 builder.inst_id_code(inst_id_code);
3177
3178 builder.td_mode(td_mode);
3179 builder.cl_ord_id(client_order_id.as_str());
3180 builder.side(order_side.as_specified());
3181 builder.ord_type(
3182 conditional_order_to_algo_type(order_type)
3183 .map_err(|e| OKXWsError::ClientError(e.to_string()))?,
3184 );
3185 builder.sz(quantity.to_string());
3186
3187 if let Some(tp) = trigger_price {
3188 builder.trigger_px(tp.to_string());
3189 }
3190
3191 let okx_trigger_type = trigger_type.map_or(OKXTriggerType::Last, Into::into);
3193 builder.trigger_px_type(okx_trigger_type);
3194
3195 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
3197 && let Some(price) = limit_price
3198 {
3199 builder.order_px(price.to_string());
3200 }
3201
3202 if let Some(reduce) = reduce_only {
3203 builder.reduce_only(reduce);
3204 }
3205
3206 if let Some(ratio) = callback_ratio {
3207 builder.callback_ratio(ratio);
3208 }
3209
3210 if let Some(spread) = callback_spread {
3211 builder.callback_spread(spread);
3212 }
3213
3214 if let Some(active) = activation_price {
3215 builder.active_px(active.to_string());
3216 }
3217
3218 builder.tag(OKX_NAUTILUS_BROKER_ID);
3219
3220 let params = builder
3221 .build()
3222 .map_err(|e| OKXWsError::ClientError(format!("Build algo order params error: {e}")))?;
3223
3224 let request_id = self.generate_unique_request_id();
3225 let request = OKXWsRequest {
3226 id: Some(request_id.clone()),
3227 op: super::enums::OKXWsOperation::OrderAlgo,
3228 exp_time: None,
3229 args: vec![params],
3230 };
3231
3232 let payload = serde_json::to_string(&request)
3233 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize algo order: {e}")))?;
3234
3235 let cmd = HandlerCommand::Send {
3236 payload,
3237 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_ORDER.to_vec()),
3238 request_id: Some(request_id),
3239 client_order_id: Some(client_order_id),
3240 op: Some(super::enums::OKXWsOperation::OrderAlgo),
3241 };
3242
3243 self.send_cmd(cmd).await
3244 }
3245
3246 pub async fn cancel_algo_order(
3257 &self,
3258 _trader_id: TraderId,
3259 _strategy_id: StrategyId,
3260 instrument_id: InstrumentId,
3261 client_order_id: Option<ClientOrderId>,
3262 algo_order_id: Option<String>,
3263 ) -> Result<(), OKXWsError> {
3264 let mut builder = super::messages::WsCancelAlgoOrderParamsBuilder::default();
3265
3266 let inst_id_code = self
3267 .get_inst_id_code(&instrument_id.symbol.inner())
3268 .ok_or_else(|| {
3269 OKXWsError::ClientError(format!(
3270 "No instIdCode cached for {instrument_id}, cannot cancel algo order"
3271 ))
3272 })?;
3273 builder.inst_id_code(inst_id_code);
3274
3275 if let Some(algo_id) = algo_order_id {
3276 builder.algo_id(algo_id);
3277 }
3278
3279 if let Some(cl_ord_id) = client_order_id {
3280 builder.algo_cl_ord_id(cl_ord_id.to_string());
3281 }
3282
3283 let params = builder
3284 .build()
3285 .map_err(|e| OKXWsError::ClientError(format!("Build cancel algo params error: {e}")))?;
3286
3287 let request_id = self.generate_unique_request_id();
3288 let request = OKXWsRequest {
3289 id: Some(request_id.clone()),
3290 op: super::enums::OKXWsOperation::CancelAlgos,
3291 exp_time: None,
3292 args: vec![params],
3293 };
3294
3295 let payload = serde_json::to_string(&request)
3296 .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel algo: {e}")))?;
3297
3298 let cmd = HandlerCommand::Send {
3299 payload,
3300 rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_CANCEL.to_vec()),
3301 request_id: Some(request_id),
3302 client_order_id,
3303 op: Some(super::enums::OKXWsOperation::CancelAlgos),
3304 };
3305
3306 self.send_cmd(cmd).await
3307 }
3308
3309 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), OKXWsError> {
3311 self.cmd_tx
3312 .read()
3313 .await
3314 .send(cmd)
3315 .map_err(|e| OKXWsError::ClientError(format!("Handler not available: {e}")))
3316 }
3317}
3318
3319fn log_receiver_dropped(signal: &AtomicBool, item: &str) {
3320 if signal.load(Ordering::Acquire) {
3321 log::debug!("Receiver dropped after stop signal while forwarding {item}");
3322 } else {
3323 log::error!("Failed to send {item} through channel: receiver dropped");
3324 }
3325}
3326
3327#[cfg(test)]
3328mod tests {
3329 use nautilus_core::time::get_atomic_clock_realtime;
3330 use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
3331 use nautilus_network::RECONNECTED;
3332 use rstest::rstest;
3333 use tokio_tungstenite::tungstenite::Message;
3334
3335 use super::*;
3336 use crate::{
3337 common::{
3338 consts::OKX_POST_ONLY_CANCEL_SOURCE,
3339 enums::{
3340 OKXExecType, OKXOrderCategory, OKXOrderStatus, OKXPriceType, OKXQuickMarginType,
3341 OKXSelfTradePreventionMode, OKXSide,
3342 },
3343 },
3344 websocket::{
3345 handler::is_post_only_auto_cancel,
3346 messages::{OKXOrderMsg, OKXWebSocketError, OKXWsFrame},
3347 },
3348 };
3349
3350 #[rstest]
3351 fn test_timestamp_format_for_websocket_auth() {
3352 let timestamp = SystemTime::now()
3353 .duration_since(SystemTime::UNIX_EPOCH)
3354 .expect("System time should be after UNIX epoch")
3355 .as_secs()
3356 .to_string();
3357
3358 timestamp.parse::<u64>().unwrap();
3359 assert_eq!(timestamp.len(), 10);
3360 assert!(timestamp.chars().all(|c| c.is_ascii_digit()));
3361 }
3362
3363 #[rstest]
3364 fn test_new_without_credentials() {
3365 let client = OKXWebSocketClient::default();
3366 assert!(client.credential.is_none());
3367 assert_eq!(client.api_key(), None);
3368 }
3369
3370 #[rstest]
3371 fn test_instruments_cache_arc_observes_post_clone_writes() {
3372 let client = OKXWebSocketClient::default();
3373 let cache = client.instruments_cache_arc();
3374 assert!(cache.load().is_empty());
3375
3376 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3377 let symbol = instrument.symbol().inner();
3378 client.cache_instruments(std::slice::from_ref(&instrument));
3379
3380 let loaded = cache.load();
3381 assert_eq!(loaded.len(), 1);
3382 let stored = loaded.get(&symbol).expect("instrument not refreshed");
3383 assert_eq!(stored.id(), instrument.id());
3384 }
3385
3386 #[rstest]
3387 fn test_add_option_greeks_sub_defaults_to_both_conventions() {
3388 let client = OKXWebSocketClient::default();
3389 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3390
3391 client.add_option_greeks_sub(instrument_id);
3392
3393 let subs = client.option_greeks_subs().load();
3394 let stored = subs.get(&instrument_id).expect("instrument not registered");
3395 assert_eq!(stored.len(), 2);
3396 assert!(stored.contains(&OKXGreeksType::Bs));
3397 assert!(stored.contains(&OKXGreeksType::Pa));
3398 }
3399
3400 #[rstest]
3401 #[case::bs_only(vec![OKXGreeksType::Bs])]
3402 #[case::pa_only(vec![OKXGreeksType::Pa])]
3403 #[case::both(vec![OKXGreeksType::Bs, OKXGreeksType::Pa])]
3404 fn test_add_option_greeks_sub_with_conventions_stores_requested_set(
3405 #[case] conventions: Vec<OKXGreeksType>,
3406 ) {
3407 let client = OKXWebSocketClient::default();
3408 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3409 let set: AHashSet<OKXGreeksType> = conventions.iter().copied().collect();
3410
3411 client.add_option_greeks_sub_with_conventions(instrument_id, set.clone());
3412
3413 let subs = client.option_greeks_subs().load();
3414 let stored = subs.get(&instrument_id).expect("instrument not registered");
3415 assert_eq!(stored, &set);
3416 }
3417
3418 #[rstest]
3419 fn test_add_option_greeks_sub_with_empty_conventions_falls_back_to_both() {
3420 let client = OKXWebSocketClient::default();
3421 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3422
3423 client.add_option_greeks_sub_with_conventions(instrument_id, AHashSet::new());
3424
3425 let subs = client.option_greeks_subs().load();
3426 let stored = subs.get(&instrument_id).expect("instrument not registered");
3427 assert_eq!(stored.len(), 2);
3428 }
3429
3430 #[rstest]
3431 fn test_remove_option_greeks_sub_clears_entry() {
3432 let client = OKXWebSocketClient::default();
3433 let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3434
3435 client.add_option_greeks_sub(instrument_id);
3436 client.remove_option_greeks_sub(&instrument_id);
3437
3438 let subs = client.option_greeks_subs().load();
3439 assert!(!subs.contains_key(&instrument_id));
3440 }
3441
3442 #[rstest]
3443 fn test_new_with_credentials() {
3444 let client = OKXWebSocketClient::new(
3445 None,
3446 Some("test_key".to_string()),
3447 Some("test_secret".to_string()),
3448 Some("test_passphrase".to_string()),
3449 None,
3450 None,
3451 None,
3452 TransportBackend::default(),
3453 None,
3454 )
3455 .unwrap();
3456 assert!(client.credential.is_some());
3457 assert_eq!(client.api_key(), Some("test_key"));
3458 }
3459
3460 #[rstest]
3461 fn test_new_partial_credentials_fails() {
3462 let result = OKXWebSocketClient::new(
3463 None,
3464 Some("test_key".to_string()),
3465 None,
3466 Some("test_passphrase".to_string()),
3467 None,
3468 None,
3469 None,
3470 TransportBackend::default(),
3471 None,
3472 );
3473 result.unwrap_err();
3474 }
3475
3476 #[rstest]
3477 fn test_request_id_generation() {
3478 let client = OKXWebSocketClient::default();
3479
3480 let initial_counter = client.request_id_counter.load(Ordering::SeqCst);
3481
3482 let id1 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3483 let id2 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3484
3485 assert_eq!(id1, initial_counter);
3486 assert_eq!(id2, initial_counter + 1);
3487 assert_eq!(
3488 client.request_id_counter.load(Ordering::SeqCst),
3489 initial_counter + 2
3490 );
3491 }
3492
3493 #[rstest]
3494 fn test_client_state_management() {
3495 let client = OKXWebSocketClient::default();
3496
3497 assert!(client.is_closed());
3498 assert!(!client.is_active());
3499
3500 let client_with_heartbeat = OKXWebSocketClient::new(
3501 None,
3502 None,
3503 None,
3504 None,
3505 None,
3506 Some(30),
3507 None,
3508 TransportBackend::default(),
3509 None,
3510 )
3511 .unwrap();
3512
3513 assert!(client_with_heartbeat.heartbeat.is_some());
3514 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
3515 }
3516
3517 #[rstest]
3518 fn test_websocket_error_handling() {
3519 let clock = get_atomic_clock_realtime();
3520 let ts = clock.get_time_ns().as_u64();
3521
3522 let error = OKXWebSocketError {
3523 code: "60012".to_string(),
3524 message: "Invalid request".to_string(),
3525 conn_id: None,
3526 timestamp: ts,
3527 };
3528
3529 assert_eq!(error.code, "60012");
3530 assert_eq!(error.message, "Invalid request");
3531 assert_eq!(error.timestamp, ts);
3532
3533 let nautilus_msg = OKXWsMessage::Error(error);
3534 match nautilus_msg {
3535 OKXWsMessage::Error(e) => {
3536 assert_eq!(e.code, "60012");
3537 assert_eq!(e.message, "Invalid request");
3538 }
3539 _ => panic!("Expected Error variant"),
3540 }
3541 }
3542
3543 #[rstest]
3544 fn test_request_id_generation_sequence() {
3545 let client = OKXWebSocketClient::default();
3546
3547 let initial_counter = client
3548 .request_id_counter
3549 .load(std::sync::atomic::Ordering::SeqCst);
3550 let mut ids = Vec::new();
3551
3552 for _ in 0..10 {
3553 let id = client
3554 .request_id_counter
3555 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3556 ids.push(id);
3557 }
3558
3559 for (i, &id) in ids.iter().enumerate() {
3560 assert_eq!(id, initial_counter + i as u64);
3561 }
3562
3563 assert_eq!(
3564 client
3565 .request_id_counter
3566 .load(std::sync::atomic::Ordering::SeqCst),
3567 initial_counter + 10
3568 );
3569 }
3570
3571 #[rstest]
3572 fn test_client_state_transitions() {
3573 let client = OKXWebSocketClient::default();
3574
3575 assert!(client.is_closed());
3576 assert!(!client.is_active());
3577
3578 let client_with_heartbeat = OKXWebSocketClient::new(
3579 None,
3580 None,
3581 None,
3582 None,
3583 None,
3584 Some(30), None,
3586 TransportBackend::default(),
3587 None,
3588 )
3589 .unwrap();
3590
3591 assert!(client_with_heartbeat.heartbeat.is_some());
3592 assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
3593
3594 let account_id = AccountId::from("test-account-123");
3595 let client_with_account = OKXWebSocketClient::new(
3596 None,
3597 None,
3598 None,
3599 None,
3600 Some(account_id),
3601 None,
3602 None,
3603 TransportBackend::default(),
3604 None,
3605 )
3606 .unwrap();
3607
3608 assert_eq!(client_with_account.account_id, account_id);
3609 }
3610
3611 #[rstest]
3612 fn test_websocket_error_scenarios() {
3613 let clock = get_atomic_clock_realtime();
3614 let ts = clock.get_time_ns().as_u64();
3615
3616 let error_scenarios = vec![
3617 ("60012", "Invalid request", None),
3618 ("60009", "Invalid API key", Some("conn-123".to_string())),
3619 ("60014", "Too many requests", None),
3620 ("50001", "Order not found", None),
3621 ];
3622
3623 for (code, message, conn_id) in error_scenarios {
3624 let error = OKXWebSocketError {
3625 code: code.to_string(),
3626 message: message.to_string(),
3627 conn_id: conn_id.clone(),
3628 timestamp: ts,
3629 };
3630
3631 assert_eq!(error.code, code);
3632 assert_eq!(error.message, message);
3633 assert_eq!(error.conn_id, conn_id);
3634 assert_eq!(error.timestamp, ts);
3635
3636 let nautilus_msg = OKXWsMessage::Error(error);
3637 match nautilus_msg {
3638 OKXWsMessage::Error(e) => {
3639 assert_eq!(e.code, code);
3640 assert_eq!(e.message, message);
3641 assert_eq!(e.conn_id, conn_id);
3642 }
3643 _ => panic!("Expected Error variant"),
3644 }
3645 }
3646 }
3647
3648 #[rstest]
3649 fn test_feed_handler_reconnection_detection() {
3650 let msg = Message::Text(RECONNECTED.to_string().into());
3651 let result = OKXWsFeedHandler::parse_raw_message(msg);
3652 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
3653 }
3654
3655 #[rstest]
3656 fn test_feed_handler_normal_message_processing() {
3657 let ping_msg = Message::Text(TEXT_PING.to_string().into());
3658 let result = OKXWsFeedHandler::parse_raw_message(ping_msg);
3659 assert!(matches!(result, Some(OKXWsFrame::Ping)));
3660
3661 let sub_msg = r#"{
3662 "event": "subscribe",
3663 "arg": {
3664 "channel": "tickers",
3665 "instType": "SPOT"
3666 },
3667 "connId": "a4d3ae55"
3668 }"#;
3669
3670 let sub_result =
3671 OKXWsFeedHandler::parse_raw_message(Message::Text(sub_msg.to_string().into()));
3672 assert!(matches!(sub_result, Some(OKXWsFrame::Subscription { .. })));
3673 }
3674
3675 #[rstest]
3676 fn test_feed_handler_close_message() {
3677 let result = OKXWsFeedHandler::parse_raw_message(Message::Close(None));
3678 assert!(result.is_none());
3679 }
3680
3681 #[rstest]
3682 fn test_reconnection_message_constant() {
3683 assert_eq!(RECONNECTED, "__RECONNECTED__");
3684 }
3685
3686 #[rstest]
3687 fn test_multiple_reconnection_signals() {
3688 for _ in 0..3 {
3689 let msg = Message::Text(RECONNECTED.to_string().into());
3690 let result = OKXWsFeedHandler::parse_raw_message(msg);
3691 assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
3692 }
3693 }
3694
3695 #[tokio::test]
3696 async fn test_wait_until_active_timeout() {
3697 let client = OKXWebSocketClient::new(
3698 None,
3699 Some("test_key".to_string()),
3700 Some("test_secret".to_string()),
3701 Some("test_passphrase".to_string()),
3702 Some(AccountId::from("test-account")),
3703 None,
3704 None,
3705 TransportBackend::default(),
3706 None,
3707 )
3708 .unwrap();
3709
3710 let result = client.wait_until_active(0.1).await;
3711
3712 assert!(result.is_err());
3713 assert!(!client.is_active());
3714 }
3715
3716 fn sample_canceled_order_msg() -> OKXOrderMsg {
3717 OKXOrderMsg {
3718 acc_fill_sz: Some("0".to_string()),
3719 avg_px: "0".to_string(),
3720 c_time: 0,
3721 cancel_source: None,
3722 cancel_source_reason: None,
3723 category: OKXOrderCategory::Normal,
3724 ccy: Ustr::from("USDT"),
3725 cl_ord_id: "order-1".to_string(),
3726 algo_cl_ord_id: None,
3727 attach_algo_cl_ord_id: None,
3728 attach_algo_ords: Vec::new(),
3729 outcome: None,
3730 fee: None,
3731 fee_ccy: Ustr::from("USDT"),
3732 fill_px: "0".to_string(),
3733 fill_sz: "0".to_string(),
3734 fill_time: 0,
3735 inst_id: Ustr::from("ETH-USDT-SWAP"),
3736 inst_type: OKXInstrumentType::Swap,
3737 lever: "1".to_string(),
3738 ord_id: Ustr::from("123456"),
3739 ord_type: OKXOrderType::Limit,
3740 pnl: "0".to_string(),
3741 pos_side: OKXPositionSide::Net,
3742 px: "0".to_string(),
3743 reduce_only: "false".to_string(),
3744 side: OKXSide::Buy,
3745 state: OKXOrderStatus::Canceled,
3746 exec_type: OKXExecType::None,
3747 sz: "1".to_string(),
3748 td_mode: OKXTradeMode::Cross,
3749 tgt_ccy: None,
3750 trade_id: String::new(),
3751 algo_id: None,
3752 fill_fee: None,
3753 fill_fee_ccy: None,
3754 fill_mark_px: None,
3755 fill_mark_vol: None,
3756 fill_px_vol: None,
3757 fill_px_usd: None,
3758 fill_fwd_px: None,
3759 fill_notional_usd: None,
3760 fill_pnl: None,
3761 is_tp_limit: None,
3762 linked_algo_ord: None,
3763 notional_usd: None,
3764 px_type: OKXPriceType::None,
3765 px_usd: None,
3766 px_vol: None,
3767 quick_mgn_type: OKXQuickMarginType::None,
3768 rebate: None,
3769 rebate_ccy: None,
3770 sl_ord_px: None,
3771 sl_trigger_px: None,
3772 sl_trigger_px_type: None,
3773 source: None,
3774 stp_id: None,
3775 stp_mode: OKXSelfTradePreventionMode::None,
3776 tag: None,
3777 tp_ord_px: None,
3778 tp_trigger_px: None,
3779 tp_trigger_px_type: None,
3780 amend_result: None,
3781 req_id: None,
3782 code: None,
3783 msg: None,
3784 u_time: 0,
3785 }
3786 }
3787
3788 #[rstest]
3789 fn test_is_post_only_auto_cancel_detects_cancel_source() {
3790 let mut msg = sample_canceled_order_msg();
3791 msg.cancel_source = Some(OKX_POST_ONLY_CANCEL_SOURCE.to_string());
3792
3793 assert!(is_post_only_auto_cancel(&msg));
3794 }
3795
3796 #[rstest]
3797 fn test_is_post_only_auto_cancel_detects_reason() {
3798 let mut msg = sample_canceled_order_msg();
3799 msg.cancel_source_reason = Some("POST_ONLY would take liquidity".to_string());
3800
3801 assert!(is_post_only_auto_cancel(&msg));
3802 }
3803
3804 #[rstest]
3805 fn test_is_post_only_auto_cancel_false_without_markers() {
3806 let msg = sample_canceled_order_msg();
3807
3808 assert!(!is_post_only_auto_cancel(&msg));
3809 }
3810
3811 #[rstest]
3812 fn test_is_post_only_auto_cancel_false_for_order_type_only() {
3813 let mut msg = sample_canceled_order_msg();
3814 msg.ord_type = OKXOrderType::PostOnly;
3815
3816 assert!(!is_post_only_auto_cancel(&msg));
3817 }
3818
3819 #[tokio::test]
3820 async fn test_batch_cancel_orders_with_multiple_orders() {
3821 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
3822
3823 let client = OKXWebSocketClient::new(
3824 Some("wss://test.okx.com".to_string()),
3825 None,
3826 None,
3827 None,
3828 None,
3829 None,
3830 None,
3831 TransportBackend::default(),
3832 None,
3833 )
3834 .expect("Failed to create client");
3835
3836 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3837 let client_order_id1 = ClientOrderId::new("order1");
3838 let client_order_id2 = ClientOrderId::new("order2");
3839 let venue_order_id1 = VenueOrderId::new("venue1");
3840 let venue_order_id2 = VenueOrderId::new("venue2");
3841
3842 let orders = vec![
3843 (instrument_id, Some(client_order_id1), Some(venue_order_id1)),
3844 (instrument_id, Some(client_order_id2), Some(venue_order_id2)),
3845 ];
3846
3847 let result = client.batch_cancel_orders(orders).await;
3848 assert!(result.is_err());
3849 }
3850
3851 #[tokio::test]
3852 async fn test_batch_cancel_orders_with_only_client_order_id() {
3853 use nautilus_model::identifiers::{ClientOrderId, InstrumentId};
3854
3855 let client = OKXWebSocketClient::new(
3856 Some("wss://test.okx.com".to_string()),
3857 None,
3858 None,
3859 None,
3860 None,
3861 None,
3862 None,
3863 TransportBackend::default(),
3864 None,
3865 )
3866 .expect("Failed to create client");
3867
3868 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3869 let client_order_id = ClientOrderId::new("order1");
3870
3871 let orders = vec![(instrument_id, Some(client_order_id), None)];
3872
3873 let result = client.batch_cancel_orders(orders).await;
3874
3875 assert!(result.is_err());
3876 }
3877
3878 #[tokio::test]
3879 async fn test_batch_cancel_orders_with_only_venue_order_id() {
3880 use nautilus_model::identifiers::{InstrumentId, VenueOrderId};
3881
3882 let client = OKXWebSocketClient::new(
3883 Some("wss://test.okx.com".to_string()),
3884 None,
3885 None,
3886 None,
3887 None,
3888 None,
3889 None,
3890 TransportBackend::default(),
3891 None,
3892 )
3893 .expect("Failed to create client");
3894
3895 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3896 let venue_order_id = VenueOrderId::new("venue1");
3897
3898 let orders = vec![(instrument_id, None, Some(venue_order_id))];
3899
3900 let result = client.batch_cancel_orders(orders).await;
3901
3902 assert!(result.is_err());
3903 }
3904
3905 #[tokio::test]
3906 async fn test_batch_cancel_orders_with_both_ids() {
3907 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
3908
3909 let client = OKXWebSocketClient::new(
3910 Some("wss://test.okx.com".to_string()),
3911 None,
3912 None,
3913 None,
3914 None,
3915 None,
3916 None,
3917 TransportBackend::default(),
3918 None,
3919 )
3920 .expect("Failed to create client");
3921
3922 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3923 let client_order_id = ClientOrderId::new("order1");
3924 let venue_order_id = VenueOrderId::new("venue1");
3925
3926 let orders = vec![(instrument_id, Some(client_order_id), Some(venue_order_id))];
3927
3928 let result = client.batch_cancel_orders(orders).await;
3929
3930 assert!(result.is_err());
3931 }
3932
3933 #[tokio::test]
3934 async fn test_cancel_order_fails_without_inst_id_code() {
3935 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
3936
3937 let client = OKXWebSocketClient::default();
3938 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3939
3940 let result = client
3941 .cancel_order(
3942 TraderId::from("TESTER-001"),
3943 StrategyId::from("S-001"),
3944 instrument_id,
3945 Some(ClientOrderId::new("O-001")),
3946 None,
3947 )
3948 .await;
3949
3950 assert!(result.is_err());
3951 let err = result.unwrap_err().to_string();
3952 assert!(
3953 err.contains("No instIdCode cached for BTC-USDT-SWAP.OKX"),
3954 "Expected instIdCode error, found: {err}"
3955 );
3956 }
3957
3958 #[tokio::test]
3959 async fn test_submit_order_fails_without_inst_id_code() {
3960 use nautilus_model::{
3961 enums::{OrderSide, OrderType},
3962 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
3963 types::Quantity,
3964 };
3965
3966 use crate::common::enums::OKXTradeMode;
3967
3968 let client = OKXWebSocketClient::default();
3969 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3970
3971 let result = client
3972 .submit_order(
3973 TraderId::from("TESTER-001"),
3974 StrategyId::from("S-001"),
3975 instrument_id,
3976 OKXTradeMode::Cross,
3977 ClientOrderId::new("O-001"),
3978 OrderSide::Buy,
3979 OrderType::Limit,
3980 Quantity::from("0.01"),
3981 None,
3982 None,
3983 None,
3984 None,
3985 None,
3986 None,
3987 None,
3988 None,
3989 None,
3990 None,
3991 None,
3992 None,
3993 None,
3994 )
3995 .await;
3996
3997 assert!(result.is_err());
3998 let err = result.unwrap_err().to_string();
3999 assert!(
4000 err.contains("No instIdCode cached for ETH-USDT-SWAP.OKX"),
4001 "Expected instIdCode error, found: {err}"
4002 );
4003 }
4004
4005 #[tokio::test]
4006 async fn test_cancel_order_passes_inst_id_code_lookup_when_cached() {
4007 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4008 use ustr::Ustr;
4009
4010 let client = OKXWebSocketClient::default();
4011 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4012
4013 client.cache_inst_id_code(Ustr::from("BTC-USDT-SWAP"), 10459);
4015
4016 let result = client
4017 .cancel_order(
4018 TraderId::from("TESTER-001"),
4019 StrategyId::from("S-001"),
4020 instrument_id,
4021 Some(ClientOrderId::new("O-001")),
4022 None,
4023 )
4024 .await;
4025
4026 assert!(result.is_err());
4028 let err = result.unwrap_err().to_string();
4029 assert!(
4030 !err.contains("No instIdCode cached"),
4031 "Should pass instIdCode lookup, found: {err}"
4032 );
4033 }
4034
4035 #[rstest]
4036 fn test_race_unsubscribe_failure_recovery() {
4037 let client = OKXWebSocketClient::new(
4043 Some("wss://test.okx.com".to_string()),
4044 None,
4045 None,
4046 None,
4047 None,
4048 None,
4049 None,
4050 TransportBackend::default(),
4051 None,
4052 )
4053 .expect("Failed to create client");
4054
4055 let topic = "trades:BTC-USDT-SWAP";
4056
4057 client.subscriptions_state.mark_subscribe(topic);
4059 client.subscriptions_state.confirm_subscribe(topic);
4060 assert_eq!(client.subscriptions_state.len(), 1);
4061
4062 client.subscriptions_state.mark_unsubscribe(topic);
4064 assert_eq!(client.subscriptions_state.len(), 0);
4065 assert_eq!(
4066 client.subscriptions_state.pending_unsubscribe_topics(),
4067 vec![topic]
4068 );
4069
4070 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);
4078 assert!(
4079 client
4080 .subscriptions_state
4081 .pending_unsubscribe_topics()
4082 .is_empty()
4083 );
4084 assert!(
4085 client
4086 .subscriptions_state
4087 .pending_subscribe_topics()
4088 .is_empty()
4089 );
4090
4091 let all = client.subscriptions_state.all_topics();
4093 assert_eq!(all.len(), 1);
4094 assert!(all.contains(&topic.to_string()));
4095 }
4096
4097 #[rstest]
4098 fn test_race_resubscribe_before_unsubscribe_ack() {
4099 let client = OKXWebSocketClient::new(
4103 Some("wss://test.okx.com".to_string()),
4104 None,
4105 None,
4106 None,
4107 None,
4108 None,
4109 None,
4110 TransportBackend::default(),
4111 None,
4112 )
4113 .expect("Failed to create client");
4114
4115 let topic = "books:BTC-USDT";
4116
4117 client.subscriptions_state.mark_subscribe(topic);
4119 client.subscriptions_state.confirm_subscribe(topic);
4120 assert_eq!(client.subscriptions_state.len(), 1);
4121
4122 client.subscriptions_state.mark_unsubscribe(topic);
4124 assert_eq!(client.subscriptions_state.len(), 0);
4125 assert_eq!(
4126 client.subscriptions_state.pending_unsubscribe_topics(),
4127 vec![topic]
4128 );
4129
4130 client.subscriptions_state.mark_subscribe(topic);
4132 assert_eq!(
4133 client.subscriptions_state.pending_subscribe_topics(),
4134 vec![topic]
4135 );
4136
4137 client.subscriptions_state.confirm_unsubscribe(topic);
4139 assert!(
4140 client
4141 .subscriptions_state
4142 .pending_unsubscribe_topics()
4143 .is_empty()
4144 );
4145 assert_eq!(
4146 client.subscriptions_state.pending_subscribe_topics(),
4147 vec![topic]
4148 );
4149
4150 client.subscriptions_state.confirm_subscribe(topic);
4152 assert_eq!(client.subscriptions_state.len(), 1);
4153 assert!(
4154 client
4155 .subscriptions_state
4156 .pending_subscribe_topics()
4157 .is_empty()
4158 );
4159
4160 let all = client.subscriptions_state.all_topics();
4162 assert_eq!(all.len(), 1);
4163 assert!(all.contains(&topic.to_string()));
4164 }
4165
4166 #[rstest]
4167 fn test_race_late_subscribe_confirmation_after_unsubscribe() {
4168 let client = OKXWebSocketClient::new(
4171 Some("wss://test.okx.com".to_string()),
4172 None,
4173 None,
4174 None,
4175 None,
4176 None,
4177 None,
4178 TransportBackend::default(),
4179 None,
4180 )
4181 .expect("Failed to create client");
4182
4183 let topic = "tickers:ETH-USDT";
4184
4185 client.subscriptions_state.mark_subscribe(topic);
4187 assert_eq!(
4188 client.subscriptions_state.pending_subscribe_topics(),
4189 vec![topic]
4190 );
4191
4192 client.subscriptions_state.mark_unsubscribe(topic);
4194 assert!(
4195 client
4196 .subscriptions_state
4197 .pending_subscribe_topics()
4198 .is_empty()
4199 ); assert_eq!(
4201 client.subscriptions_state.pending_unsubscribe_topics(),
4202 vec![topic]
4203 );
4204
4205 client.subscriptions_state.confirm_subscribe(topic);
4207 assert_eq!(client.subscriptions_state.len(), 0); assert_eq!(
4209 client.subscriptions_state.pending_unsubscribe_topics(),
4210 vec![topic]
4211 );
4212
4213 client.subscriptions_state.confirm_unsubscribe(topic);
4215
4216 assert!(client.subscriptions_state.is_empty());
4218 assert!(client.subscriptions_state.all_topics().is_empty());
4219 }
4220
4221 #[rstest]
4222 fn test_race_reconnection_with_pending_states() {
4223 let client = OKXWebSocketClient::new(
4225 Some("wss://test.okx.com".to_string()),
4226 Some("test_key".to_string()),
4227 Some("test_secret".to_string()),
4228 Some("test_passphrase".to_string()),
4229 Some(AccountId::new("OKX-TEST")),
4230 None,
4231 None,
4232 TransportBackend::default(),
4233 None,
4234 )
4235 .expect("Failed to create client");
4236
4237 let trade_btc = "trades:BTC-USDT-SWAP";
4240 client.subscriptions_state.mark_subscribe(trade_btc);
4241 client.subscriptions_state.confirm_subscribe(trade_btc);
4242
4243 let trade_eth = "trades:ETH-USDT-SWAP";
4245 client.subscriptions_state.mark_subscribe(trade_eth);
4246
4247 let book_btc = "books:BTC-USDT";
4249 client.subscriptions_state.mark_subscribe(book_btc);
4250 client.subscriptions_state.confirm_subscribe(book_btc);
4251 client.subscriptions_state.mark_unsubscribe(book_btc);
4252
4253 let topics_to_restore = client.subscriptions_state.all_topics();
4255
4256 assert_eq!(topics_to_restore.len(), 2);
4258 assert!(topics_to_restore.contains(&trade_btc.to_string()));
4259 assert!(topics_to_restore.contains(&trade_eth.to_string()));
4260 assert!(!topics_to_restore.contains(&book_btc.to_string())); }
4262
4263 #[rstest]
4264 fn test_race_duplicate_subscribe_messages_idempotent() {
4265 let client = OKXWebSocketClient::new(
4268 Some("wss://test.okx.com".to_string()),
4269 None,
4270 None,
4271 None,
4272 None,
4273 None,
4274 None,
4275 TransportBackend::default(),
4276 None,
4277 )
4278 .expect("Failed to create client");
4279
4280 let topic = "trades:BTC-USDT-SWAP";
4281
4282 client.subscriptions_state.mark_subscribe(topic);
4284 client.subscriptions_state.confirm_subscribe(topic);
4285 assert_eq!(client.subscriptions_state.len(), 1);
4286
4287 client.subscriptions_state.mark_subscribe(topic);
4289 assert!(
4290 client
4291 .subscriptions_state
4292 .pending_subscribe_topics()
4293 .is_empty()
4294 ); assert_eq!(client.subscriptions_state.len(), 1); client.subscriptions_state.confirm_subscribe(topic);
4299 assert_eq!(client.subscriptions_state.len(), 1);
4300
4301 let all = client.subscriptions_state.all_topics();
4303 assert_eq!(all.len(), 1);
4304 assert_eq!(all[0], topic);
4305 }
4306}