Skip to main content

nautilus_okx/websocket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the WebSocket client integration for the [OKX](https://okx.com) WebSocket API.
17//!
18//! The [`OKXWebSocketClient`] ties together several recurring patterns:
19//! - Heartbeats use text `ping`/`pong`, responding to both text and control-frame pings.
20//! - Authentication re-runs on reconnect before resubscribing and skips private channels when
21//!   credentials are unavailable.
22//! - Subscriptions cache instrument type/family/ID groupings so reconnects rebuild the same set of
23//!   channels while respecting the authentication guard described above.
24
25use 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_core::{
40    AtomicMap,
41    consts::NAUTILUS_USER_AGENT,
42    env::{get_env_var, get_or_env_var},
43    string::secret::REDACTED,
44};
45use nautilus_live::{
46    SocketControl,
47    task::{TaskGroup, TaskShutdownError},
48};
49use nautilus_model::{
50    data::BarType,
51    enums::{OrderSide, OrderType, PositionSide, TimeInForce, TriggerType},
52    identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
53    instruments::{Instrument, InstrumentAny},
54    types::{Price, Quantity},
55};
56use nautilus_network::{
57    http::USER_AGENT,
58    mode::ConnectionMode,
59    ratelimiter::quota::Quota,
60    websocket::{
61        AUTHENTICATION_TIMEOUT_SECS, AuthTracker, SubscriptionState, TEXT_PING, TransportBackend,
62        WebSocketClient, WebSocketConfig, channel_message_handler,
63    },
64};
65use parking_lot::Mutex;
66use serde_json::Value;
67use tokio_tungstenite::tungstenite::Error;
68use tokio_util::sync::CancellationToken;
69use ustr::Ustr;
70
71use super::{
72    enums::OKXWsChannel,
73    error::OKXWsError,
74    handler::{HandlerCommand, OKXWsFeedHandler},
75    messages::{
76        OKXAuthentication, OKXAuthenticationArg, OKXSubscriptionArg, OKXWsMessage, OKXWsRequest,
77        WsAmendOrderParamsBuilder, WsAttachAlgoOrdParams, WsCancelOrderParamsBuilder,
78        WsMassCancelParams, WsPostAlgoOrderParamsBuilder, WsPostOrderParamsBuilder,
79    },
80    subscription::topic_from_subscription_arg,
81};
82use crate::common::{
83    consts::{
84        OKX_NAUTILUS_BROKER_ID, OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
85        OKX_WS_PUBLIC_URL, OKX_WS_TOPIC_DELIMITER, select_book_channel,
86    },
87    credential::Credential,
88    enums::{
89        OKXBookChannel, OKXGreeksType, OKXInstrumentType, OKXOrderType, OKXPositionSide,
90        OKXTargetCurrency, OKXTradeMode, OKXTriggerType, OKXVipLevel,
91        conditional_order_to_algo_type, is_conditional_order,
92    },
93    parse::{
94        bar_spec_as_okx_channel, okx_instrument_type, okx_instrument_type_from_symbol,
95        parse_base_quote_from_symbol,
96    },
97};
98
99/// Default OKX WebSocket connection rate limit: 3 requests per second.
100///
101/// This applies to establishing WebSocket connections, not to subscribe/unsubscribe operations.
102pub static OKX_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
103    Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant")
104});
105
106/// OKX WebSocket subscription rate limit: 480 requests per hour per connection.
107///
108/// This applies to subscribe/unsubscribe/login operations.
109/// 480 per hour = 8 per minute, but we use per-hour for accurate limiting.
110pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
111    LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).expect("non-zero")));
112
113/// Rate limit for single order, cancel, and amend WebSocket operations: 30 requests per second.
114pub static OKX_WS_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
115    Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant")
116});
117
118/// Rate limit for batch order, cancel, and amend WebSocket operations: 7 requests per second.
119pub static OKX_WS_BATCH_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
120    Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant")
121});
122
123/// Rate limit for mass cancel WebSocket operations: 2 requests per second.
124pub static OKX_WS_MASS_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
125    Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
126});
127
128/// Rate limit for algo order WebSocket operations: 10 requests per second.
129pub static OKX_WS_ALGO_ORDER_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
130    Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
131});
132
133/// Rate limit for algo cancel WebSocket operations: 1 request per second.
134pub static OKX_WS_ALGO_CANCEL_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
135    Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
136});
137
138/// Pre-interned rate limit key for subscription operations (subscribe/unsubscribe/login).
139///
140/// See: <https://www.okx.com/docs-v5/en/#websocket-api-login>
141/// See: <https://www.okx.com/docs-v5/en/#websocket-api-subscribe>
142pub static OKX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
143    LazyLock::new(|| [Ustr::from("subscription")]);
144
145/// Pre-interned rate limit key for single regular order operations.
146///
147/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-place-order>
148pub static OKX_RATE_LIMIT_KEY_ORDER: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("order")]);
149
150/// Pre-interned rate limit key for batch order operations.
151///
152/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-place-multiple-orders>
153pub static OKX_RATE_LIMIT_KEY_BATCH_ORDER: LazyLock<[Ustr; 1]> =
154    LazyLock::new(|| [Ustr::from("batch-order")]);
155
156/// Pre-interned rate limit key for single regular cancel operations.
157///
158/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-cancel-order>
159pub static OKX_RATE_LIMIT_KEY_CANCEL: LazyLock<[Ustr; 1]> =
160    LazyLock::new(|| [Ustr::from("cancel")]);
161
162/// Pre-interned rate limit key for batch cancel operations.
163///
164/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-cancel-multiple-orders>
165pub static OKX_RATE_LIMIT_KEY_BATCH_CANCEL: LazyLock<[Ustr; 1]> =
166    LazyLock::new(|| [Ustr::from("batch-cancel")]);
167
168/// Pre-interned rate limit key for mass cancel operations.
169///
170/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-mass-cancel-order>
171pub static OKX_RATE_LIMIT_KEY_MASS_CANCEL: LazyLock<[Ustr; 1]> =
172    LazyLock::new(|| [Ustr::from("mass-cancel")]);
173
174/// Pre-interned rate limit key for amend operations (amend orders).
175///
176/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-amend-order>
177pub static OKX_RATE_LIMIT_KEY_AMEND: LazyLock<[Ustr; 1]> = LazyLock::new(|| [Ustr::from("amend")]);
178
179/// Pre-interned rate limit key for batch amend operations.
180///
181/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-amend-multiple-orders>
182pub static OKX_RATE_LIMIT_KEY_BATCH_AMEND: LazyLock<[Ustr; 1]> =
183    LazyLock::new(|| [Ustr::from("batch-amend")]);
184
185/// Pre-interned rate limit key for algo order operations.
186///
187/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-ws-place-algo-order>
188pub static OKX_RATE_LIMIT_KEY_ALGO_ORDER: LazyLock<[Ustr; 1]> =
189    LazyLock::new(|| [Ustr::from("algo-order")]);
190
191/// Pre-interned rate limit key for algo cancel operations.
192///
193/// See: <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-ws-cancel-algo-order>
194pub static OKX_RATE_LIMIT_KEY_ALGO_CANCEL: LazyLock<[Ustr; 1]> =
195    LazyLock::new(|| [Ustr::from("algo-cancel")]);
196
197/// Context stored at order submission time for correlating venue responses.
198///
199/// Fields are read in `python/websocket.rs` (behind the `python` feature gate).
200#[derive(Debug, Clone)]
201#[allow(dead_code)]
202pub(crate) struct PendingOrderInfo {
203    pub trader_id: TraderId,
204    pub strategy_id: StrategyId,
205    pub instrument_id: InstrumentId,
206}
207
208/// Provides a WebSocket client for connecting to [OKX](https://okx.com).
209#[derive(Clone)]
210pub struct OKXWebSocketClient {
211    url: String,
212    vip_level: Arc<AtomicU8>,
213    credential: Option<Credential>,
214    heartbeat: Option<u64>,
215    auth_timeout_secs: u64,
216    auth_tracker: AuthTracker,
217    signal: Arc<AtomicBool>,
218    connection_mode: Arc<ArcSwap<AtomicU8>>,
219    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
220    out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<OKXWsMessage>>>,
221    handler_tasks: Arc<TaskGroup>,
222    connect_lock: Arc<tokio::sync::Mutex<()>>,
223    handler_abort: Arc<Mutex<CancellationToken>>,
224    subscriptions_inst_type: Arc<DashMap<OKXWsChannel, AHashSet<OKXInstrumentType>>>,
225    subscriptions_inst_family: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
226    subscriptions_inst_id: Arc<DashMap<OKXWsChannel, AHashSet<Ustr>>>,
227    subscriptions_bare: Arc<DashMap<OKXWsChannel, bool>>,
228    subscriptions_state: SubscriptionState,
229    request_id_counter: Arc<AtomicU64>,
230    instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
231    inst_id_code_cache: Arc<AtomicMap<Ustr, u64>>,
232    pub(crate) pending_orders: Arc<DashMap<String, PendingOrderInfo>>,
233    pub(crate) pending_cancels: Arc<DashMap<String, PendingOrderInfo>>,
234    pub(crate) pending_amends: Arc<DashMap<String, PendingOrderInfo>>,
235    option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
236    /// Per-base-pair refcount for the `index-tickers` channel. Multiple
237    /// instruments commonly share one base pair (e.g. `BTC-USDT-SWAP` and
238    /// `BTC-USDT-240628` both depend on `BTC-USDT`), so the venue
239    /// (un)subscribe must only fire on the 0↔1 transitions. Without this
240    /// refcount, a Python caller unsubscribing one instrument would tear
241    /// down the channel for every other subscriber on the same pair.
242    index_pair_subscribers: Arc<DashMap<Ustr, usize>>,
243    /// Serializes index-tickers transitions so a concurrent
244    /// subscribe/unsubscribe pair on the same base pair cannot interleave
245    /// the refcount check with the venue send and leave the channel
246    /// unsubscribed while the local count says it is live.
247    index_pair_transition: Arc<tokio::sync::Mutex<()>>,
248    /// WebSocket transport backend (defaults to `Tungstenite`).
249    transport_backend: TransportBackend,
250    /// Optional proxy URL for the WebSocket transport.
251    proxy_url: Option<String>,
252    cancellation_token: CancellationToken,
253    socket_control: Option<Arc<SocketControl>>,
254}
255
256struct ConnectRollback {
257    handler_tasks: Arc<TaskGroup>,
258    signal: Arc<AtomicBool>,
259    handler_abort: CancellationToken,
260    socket_control: Option<Arc<SocketControl>>,
261    armed: bool,
262}
263
264impl ConnectRollback {
265    fn disarm(&mut self) {
266        self.armed = false;
267    }
268}
269
270impl Drop for ConnectRollback {
271    fn drop(&mut self) {
272        if !self.armed {
273            return;
274        }
275
276        self.handler_tasks.begin_shutdown();
277        self.signal.store(true, Ordering::Release);
278        self.handler_abort.cancel();
279
280        if let Some(control) = &self.socket_control {
281            control.deregister();
282        }
283    }
284}
285
286impl Default for OKXWebSocketClient {
287    fn default() -> Self {
288        Self::new(
289            None,
290            None,
291            None,
292            None,
293            None,
294            None,
295            None,
296            TransportBackend::default(),
297            None,
298        )
299        .unwrap()
300    }
301}
302
303impl Debug for OKXWebSocketClient {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        f.debug_struct(stringify!(OKXWebSocketClient))
306            .field("url", &self.url)
307            .field("credential", &self.credential.as_ref().map(|_| REDACTED))
308            .field("heartbeat", &self.heartbeat)
309            .finish_non_exhaustive()
310    }
311}
312
313impl OKXWebSocketClient {
314    /// Creates a new [`OKXWebSocketClient`] instance.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the request fails.
319    #[allow(clippy::too_many_arguments)]
320    pub fn new(
321        url: Option<String>,
322        api_key: Option<String>,
323        api_secret: Option<String>,
324        api_passphrase: Option<String>,
325        _account_id: Option<AccountId>,
326        heartbeat: Option<u64>,
327        auth_timeout_secs: Option<u64>,
328        transport_backend: TransportBackend,
329        proxy_url: Option<String>,
330    ) -> anyhow::Result<Self> {
331        let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
332        let credential = match (api_key, api_secret, api_passphrase) {
333            (Some(key), Some(secret), Some(passphrase)) => {
334                Some(Credential::new(key, secret, passphrase))
335            }
336            (None, None, None) => None,
337            _ => anyhow::bail!(
338                "`api_key`, `api_secret`, `api_passphrase` credentials must be provided together"
339            ),
340        };
341
342        let signal = Arc::new(AtomicBool::new(false));
343        let subscriptions_inst_type = Arc::new(DashMap::new());
344        let subscriptions_inst_family = Arc::new(DashMap::new());
345        let subscriptions_inst_id = Arc::new(DashMap::new());
346        let subscriptions_bare = Arc::new(DashMap::new());
347        let subscriptions_state = SubscriptionState::new(OKX_WS_TOPIC_DELIMITER);
348
349        Ok(Self {
350            url,
351            vip_level: Arc::new(AtomicU8::new(0)),
352            credential,
353            heartbeat,
354            auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
355            auth_tracker: AuthTracker::new(),
356            signal,
357            connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
358                ConnectionMode::Closed.as_u8(),
359            ))),
360            cmd_tx: {
361                // Placeholder channel until connect() creates the real handler and replays queued instruments
362                let (tx, _) = tokio::sync::mpsc::unbounded_channel();
363                Arc::new(tokio::sync::RwLock::new(tx))
364            },
365            out_rx: None,
366            handler_tasks: Arc::new(TaskGroup::new()),
367            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
368            handler_abort: Arc::new(Mutex::new(CancellationToken::new())),
369            subscriptions_inst_type,
370            subscriptions_inst_family,
371            subscriptions_inst_id,
372            subscriptions_bare,
373            subscriptions_state,
374            request_id_counter: Arc::new(AtomicU64::new(1)),
375            instruments_cache: Arc::new(AtomicMap::new()),
376            inst_id_code_cache: Arc::new(AtomicMap::new()),
377            pending_orders: Arc::new(DashMap::new()),
378            pending_cancels: Arc::new(DashMap::new()),
379            pending_amends: Arc::new(DashMap::new()),
380            option_greeks_subs: Arc::new(AtomicMap::new()),
381            index_pair_subscribers: Arc::new(DashMap::new()),
382            index_pair_transition: Arc::new(tokio::sync::Mutex::new(())),
383            transport_backend,
384            proxy_url,
385            cancellation_token: CancellationToken::new(),
386            socket_control: None,
387        })
388    }
389
390    /// Configures socket state reporting and reconnect control.
391    #[must_use]
392    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
393        self.socket_control = Some(Arc::new(control));
394        self
395    }
396
397    /// Creates a new [`OKXWebSocketClient`] instance.
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if credential values cannot be loaded or if the
402    /// client fails to initialize.
403    #[allow(clippy::too_many_arguments)]
404    pub fn with_credentials(
405        url: Option<String>,
406        api_key: Option<String>,
407        api_secret: Option<String>,
408        api_passphrase: Option<String>,
409        account_id: Option<AccountId>,
410        heartbeat: Option<u64>,
411        auth_timeout_secs: Option<u64>,
412        transport_backend: TransportBackend,
413        proxy_url: Option<String>,
414    ) -> anyhow::Result<Self> {
415        let url = url.unwrap_or(OKX_WS_PUBLIC_URL.to_string());
416        let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
417        let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
418        let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
419
420        Self::new(
421            Some(url),
422            Some(api_key),
423            Some(api_secret),
424            Some(api_passphrase),
425            account_id,
426            heartbeat,
427            auth_timeout_secs,
428            transport_backend,
429            proxy_url,
430        )
431    }
432
433    /// Creates a new authenticated [`OKXWebSocketClient`] using environment variables.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if required environment variables are missing or if
438    /// the client fails to initialize.
439    pub fn from_env() -> anyhow::Result<Self> {
440        let url = get_env_var("OKX_WS_URL")?;
441        let api_key = get_env_var("OKX_API_KEY")?;
442        let api_secret = get_env_var("OKX_API_SECRET")?;
443        let api_passphrase = get_env_var("OKX_API_PASSPHRASE")?;
444
445        Self::new(
446            Some(url),
447            Some(api_key),
448            Some(api_secret),
449            Some(api_passphrase),
450            None,
451            None,
452            None,
453            TransportBackend::default(),
454            None,
455        )
456    }
457
458    /// Cancel all pending WebSocket requests.
459    pub fn cancel_all_requests(&self) {
460        self.cancellation_token.cancel();
461    }
462
463    /// Get the cancellation token for this client.
464    pub fn cancellation_token(&self) -> &CancellationToken {
465        &self.cancellation_token
466    }
467
468    /// Returns the websocket url being used by the client.
469    pub fn url(&self) -> &str {
470        self.url.as_str()
471    }
472
473    /// Returns the public API key being used by the client.
474    pub fn api_key(&self) -> Option<&str> {
475        self.credential.as_ref().map(|c| c.api_key())
476    }
477
478    /// Returns a masked version of the API key for logging purposes.
479    #[must_use]
480    pub fn api_key_masked(&self) -> Option<String> {
481        self.credential.as_ref().map(|c| c.api_key_masked())
482    }
483
484    /// Returns a value indicating whether the client is active.
485    pub fn is_active(&self) -> bool {
486        let connection_mode_arc = self.connection_mode.load();
487        ConnectionMode::from_atomic(&connection_mode_arc).is_active()
488            && !self.signal.load(Ordering::Acquire)
489    }
490
491    /// Returns a value indicating whether the client is closed.
492    pub fn is_closed(&self) -> bool {
493        let connection_mode_arc = self.connection_mode.load();
494        ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
495            || self.signal.load(Ordering::Acquire)
496    }
497
498    /// Returns whether this client retains ownership of a handler task.
499    pub(crate) fn has_task(&self) -> bool {
500        !self.handler_tasks.is_empty()
501    }
502
503    /// Caches multiple instruments.
504    ///
505    /// Any existing instruments with the same symbols will be replaced.
506    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
507        self.instruments_cache.rcu(|m| {
508            for inst in instruments {
509                m.insert(inst.symbol().inner(), inst.clone());
510            }
511        });
512    }
513
514    /// Caches a single instrument.
515    ///
516    /// Any existing instrument with the same symbol will be replaced.
517    pub fn cache_instrument(&self, instrument: InstrumentAny) {
518        self.instruments_cache
519            .insert(instrument.symbol().inner(), instrument);
520    }
521
522    /// Returns a snapshot of the instruments cache as an `AHashMap`.
523    pub fn instruments_snapshot(&self) -> AHashMap<Ustr, InstrumentAny> {
524        (**self.instruments_cache.load()).clone()
525    }
526
527    /// Returns a shared handle to the live instruments cache.
528    pub fn instruments_cache_arc(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
529        Arc::clone(&self.instruments_cache)
530    }
531
532    /// Caches the instIdCode mapping for an instrument.
533    ///
534    /// The instIdCode is required for WebSocket order operations per OKX API deprecation.
535    pub fn cache_inst_id_code(&self, inst_id: Ustr, inst_id_code: u64) {
536        self.inst_id_code_cache.insert(inst_id, inst_id_code);
537    }
538
539    /// Caches multiple instIdCode mappings for instruments.
540    ///
541    /// This is typically called after loading instruments from the HTTP API.
542    pub fn cache_inst_id_codes(&self, mappings: impl IntoIterator<Item = (Ustr, u64)>) {
543        let entries: Vec<_> = mappings.into_iter().collect();
544        self.inst_id_code_cache.rcu(|m| {
545            for (inst_id, inst_id_code) in &entries {
546                m.insert(*inst_id, *inst_id_code);
547            }
548        });
549    }
550
551    /// Gets the instIdCode for an instrument.
552    ///
553    /// Returns `None` if the instrument is not in the cache.
554    #[must_use]
555    pub fn get_inst_id_code(&self, inst_id: &Ustr) -> Option<u64> {
556        self.inst_id_code_cache.load().get(inst_id).copied()
557    }
558
559    fn inst_id_symbol_and_code_from_snapshot(
560        inst_id_codes: &AHashMap<Ustr, u64>,
561        inst_id: &InstrumentId,
562        action: &str,
563    ) -> Result<(Ustr, u64), OKXWsError> {
564        let inst_id_symbol = inst_id.symbol.inner();
565        let inst_id_code = inst_id_codes.get(&inst_id_symbol).copied().ok_or_else(|| {
566            OKXWsError::ClientError(format!(
567                "No instIdCode cached for {inst_id}, cannot {action} order"
568            ))
569        })?;
570        Ok((inst_id_symbol, inst_id_code))
571    }
572
573    /// Sets the VIP level for this client.
574    ///
575    /// The VIP level determines which WebSocket channels are available.
576    pub fn set_vip_level(&self, vip_level: OKXVipLevel) {
577        self.vip_level.store(vip_level as u8, Ordering::Relaxed);
578    }
579
580    /// Gets the current VIP level.
581    pub fn vip_level(&self) -> OKXVipLevel {
582        let level = self.vip_level.load(Ordering::Relaxed);
583        OKXVipLevel::from(level)
584    }
585
586    /// Connect to the OKX WebSocket server.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if the connection process fails.
591    ///
592    /// # Panics
593    ///
594    /// Panics if subscription arguments fail to serialize to JSON.
595    pub async fn connect(&mut self) -> anyhow::Result<()> {
596        let connect_lock = Arc::clone(&self.connect_lock);
597        let _connect_guard = connect_lock.lock().await;
598
599        if !self.handler_tasks.is_empty() && !self.handler_tasks.all_finished() {
600            anyhow::bail!("Cannot connect while previous WebSocket handler task is still running");
601        }
602
603        if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
604            self.handler_tasks.begin_shutdown();
605            self.handler_tasks
606                .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2))
607                .await
608                .map_err(|e| anyhow::anyhow!("Previous WebSocket handler failed: {e}"))?;
609            self.handler_tasks.start_generation().map_err(|e| {
610                anyhow::anyhow!("Failed to start WebSocket handler task generation: {e}")
611            })?;
612        }
613        let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
614            anyhow::anyhow!("Failed to acquire WebSocket handler task spawner: {e}")
615        })?;
616        let handler_abort = CancellationToken::new();
617        *self.handler_abort.lock() = handler_abort.clone();
618        let mut rollback = ConnectRollback {
619            handler_tasks: Arc::clone(&self.handler_tasks),
620            signal: Arc::clone(&self.signal),
621            handler_abort: handler_abort.clone(),
622            socket_control: self.socket_control.clone(),
623            armed: true,
624        };
625
626        // Reset signal so is_active()/is_closed() work after a previous close()
627        self.signal.store(false, Ordering::Release);
628
629        let (message_handler, raw_rx) = channel_message_handler();
630
631        // No-op ping handler: handler owns the WebSocketClient and responds to pings directly
632        // in the message loop for minimal latency (see handler.rs TEXT_PONG response)
633        // Inbound Ping frames are answered by the transport, so no ping handler is needed;
634        // the reader routes them away from the message channel and the handler never sees them.
635
636        let headers = vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())];
637
638        let config = WebSocketConfig {
639            url: self.url.clone(),
640            headers,
641            heartbeat_interval_secs: self.heartbeat,
642            heartbeat_payload: Some(TEXT_PING.to_string()),
643            connect_timeout_ms: Some(5_000),
644            reconnect_delay_initial_ms: None,
645            reconnect_delay_max_ms: None,
646            reconnect_backoff_factor: None,
647            reconnect_jitter_ms: None,
648            reconnect_max_attempts: None,
649            heartbeat_timeout_secs: None,
650            idle_timeout_ms: None,
651            backend: self.transport_backend,
652            proxy_url: self.proxy_url.clone(),
653        };
654
655        let keyed_quotas = vec![
656            (
657                OKX_RATE_LIMIT_KEY_SUBSCRIPTION[0].as_str().to_string(),
658                *OKX_WS_SUBSCRIPTION_QUOTA,
659            ),
660            (
661                OKX_RATE_LIMIT_KEY_ORDER[0].as_str().to_string(),
662                *OKX_WS_ORDER_QUOTA,
663            ),
664            (
665                OKX_RATE_LIMIT_KEY_BATCH_ORDER[0].as_str().to_string(),
666                *OKX_WS_BATCH_ORDER_QUOTA,
667            ),
668            (
669                OKX_RATE_LIMIT_KEY_CANCEL[0].as_str().to_string(),
670                *OKX_WS_ORDER_QUOTA,
671            ),
672            (
673                OKX_RATE_LIMIT_KEY_BATCH_CANCEL[0].as_str().to_string(),
674                *OKX_WS_BATCH_ORDER_QUOTA,
675            ),
676            (
677                OKX_RATE_LIMIT_KEY_MASS_CANCEL[0].as_str().to_string(),
678                *OKX_WS_MASS_CANCEL_QUOTA,
679            ),
680            (
681                OKX_RATE_LIMIT_KEY_AMEND[0].as_str().to_string(),
682                *OKX_WS_ORDER_QUOTA,
683            ),
684            (
685                OKX_RATE_LIMIT_KEY_BATCH_AMEND[0].as_str().to_string(),
686                *OKX_WS_BATCH_ORDER_QUOTA,
687            ),
688            (
689                OKX_RATE_LIMIT_KEY_ALGO_ORDER[0].as_str().to_string(),
690                *OKX_WS_ALGO_ORDER_QUOTA,
691            ),
692            (
693                OKX_RATE_LIMIT_KEY_ALGO_CANCEL[0].as_str().to_string(),
694                *OKX_WS_ALGO_CANCEL_QUOTA,
695            ),
696        ];
697
698        let client = WebSocketClient::builder()
699            .config(config)
700            .message_handler(message_handler)
701            .keyed_quotas(keyed_quotas)
702            .default_quota(*OKX_WS_CONNECTION_QUOTA)
703            .maybe_state_sink(self.socket_control.as_ref().map(|control| control.sink()))
704            .connect()
705            .await?;
706
707        // Replace connection state so all clones see the underlying WebSocketClient's state
708        self.connection_mode.store(client.connection_mode_atomic());
709        let reconnect_handle = client.reconnect_handle();
710
711        let (msg_tx, rx) = tokio::sync::mpsc::unbounded_channel::<OKXWsMessage>();
712
713        self.out_rx = Some(Arc::new(rx));
714
715        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
716        *self.cmd_tx.write().await = cmd_tx.clone();
717
718        let signal = self.signal.clone();
719        let auth_tracker = self.auth_tracker.clone();
720        let subscriptions_state = self.subscriptions_state.clone();
721
722        let handler_task = {
723            let auth_tracker = auth_tracker.clone();
724            let signal = signal.clone();
725            let credential = self.credential.clone();
726            let cmd_tx_for_reconnect = cmd_tx.clone();
727            let subscriptions_bare = self.subscriptions_bare.clone();
728            let subscriptions_inst_type = self.subscriptions_inst_type.clone();
729            let subscriptions_inst_family = self.subscriptions_inst_family.clone();
730            let subscriptions_inst_id = self.subscriptions_inst_id.clone();
731            let mut has_reconnected = false;
732
733            async move {
734                let mut handler = OKXWsFeedHandler::new(
735                    signal.clone(),
736                    cmd_rx,
737                    raw_rx,
738                    msg_tx,
739                    auth_tracker.clone(),
740                    subscriptions_state.clone(),
741                );
742
743                // Helper closure to resubscribe all tracked subscriptions after reconnection
744                let resubscribe_all = || {
745                    for entry in subscriptions_inst_id.iter() {
746                        let (channel, inst_ids) = entry.pair();
747                        for inst_id in inst_ids {
748                            let arg = OKXSubscriptionArg {
749                                channel: channel.clone(),
750                                inst_type: None,
751                                inst_family: None,
752                                inst_id: Some(*inst_id),
753                            };
754
755                            if let Err(e) = cmd_tx_for_reconnect
756                                .send(HandlerCommand::Subscribe { args: vec![arg] })
757                            {
758                                log::error!("Failed to send resubscribe command: error={e}");
759                            }
760                        }
761                    }
762
763                    for entry in subscriptions_bare.iter() {
764                        let channel = entry.key();
765                        let arg = OKXSubscriptionArg {
766                            channel: channel.clone(),
767                            inst_type: None,
768                            inst_family: None,
769                            inst_id: None,
770                        };
771
772                        if let Err(e) =
773                            cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { args: vec![arg] })
774                        {
775                            log::error!("Failed to send resubscribe command: error={e}");
776                        }
777                    }
778
779                    for entry in subscriptions_inst_type.iter() {
780                        let (channel, inst_types) = entry.pair();
781                        for inst_type in inst_types {
782                            let arg = OKXSubscriptionArg {
783                                channel: channel.clone(),
784                                inst_type: Some(*inst_type),
785                                inst_family: None,
786                                inst_id: None,
787                            };
788
789                            if let Err(e) = cmd_tx_for_reconnect
790                                .send(HandlerCommand::Subscribe { args: vec![arg] })
791                            {
792                                log::error!("Failed to send resubscribe command: error={e}");
793                            }
794                        }
795                    }
796
797                    for entry in subscriptions_inst_family.iter() {
798                        let (channel, inst_families) = entry.pair();
799                        for inst_family in inst_families {
800                            let arg = OKXSubscriptionArg {
801                                channel: channel.clone(),
802                                inst_type: None,
803                                inst_family: Some(*inst_family),
804                                inst_id: None,
805                            };
806
807                            if let Err(e) = cmd_tx_for_reconnect
808                                .send(HandlerCommand::Subscribe { args: vec![arg] })
809                            {
810                                log::error!("Failed to send resubscribe command: error={e}");
811                            }
812                        }
813                    }
814                };
815
816                loop {
817                    let message = tokio::select! {
818                        () = handler_abort.cancelled() => {
819                            log::debug!("Handler task aborted");
820                            break;
821                        }
822                        message = handler.next() => message,
823                    };
824
825                    match message {
826                        Some(OKXWsMessage::Reconnected) => {
827                            if signal.load(Ordering::Acquire) {
828                                continue;
829                            }
830
831                            has_reconnected = true;
832
833                            subscriptions_state.reset_after_reconnect();
834
835                            if let Some(cred) = &credential {
836                                log::debug!("Re-authenticating after reconnection");
837                                let timestamp = std::time::SystemTime::now()
838                                    .duration_since(std::time::SystemTime::UNIX_EPOCH)
839                                    .expect("System time should be after UNIX epoch")
840                                    .as_secs()
841                                    .to_string();
842                                let signature =
843                                    cred.sign(&timestamp, "GET", "/users/self/verify", "");
844
845                                let auth_message = super::messages::OKXAuthentication {
846                                    op: "login",
847                                    args: vec![super::messages::OKXAuthenticationArg {
848                                        api_key: cred.api_key().to_string(),
849                                        passphrase: cred.api_passphrase().to_string(),
850                                        timestamp,
851                                        sign: signature,
852                                    }],
853                                };
854
855                                if let Ok(payload) = serde_json::to_string(&auth_message) {
856                                    if let Err(e) = cmd_tx_for_reconnect
857                                        .send(HandlerCommand::Authenticate { payload })
858                                    {
859                                        log::error!(
860                                            "Failed to send reconnection auth command: error={e}"
861                                        );
862                                    }
863                                } else {
864                                    log::error!("Failed to serialize reconnection auth message");
865                                }
866                            }
867
868                            // Unauthenticated sessions resubscribe immediately after reconnection,
869                            // authenticated sessions wait for Authenticated message
870                            if credential.is_none() {
871                                log::debug!(
872                                    "No authentication required, resubscribing immediately"
873                                );
874                                resubscribe_all();
875                            }
876
877                            // Forward Reconnected to consumers so they can reset state
878                            if handler.send(OKXWsMessage::Reconnected).is_err() {
879                                log_receiver_dropped(&signal, "Reconnected");
880                                break;
881                            }
882                        }
883                        Some(OKXWsMessage::Authenticated) => {
884                            if has_reconnected {
885                                resubscribe_all();
886                            }
887                        }
888                        Some(msg) => {
889                            if handler.send(msg).is_err() {
890                                log_receiver_dropped(&signal, "message");
891                                break;
892                            }
893                        }
894                        None => {
895                            if handler.is_stopped() {
896                                log::debug!("Stop signal received, ending message processing",);
897                                break;
898                            }
899                            log::debug!("WebSocket stream closed");
900                            break;
901                        }
902                    }
903                }
904
905                log::debug!("Handler task exiting");
906            }
907        };
908
909        if let Err(e) = handler_spawner.spawn(handler_task) {
910            self.out_rx = None;
911            anyhow::bail!("Failed to register WebSocket handler task: {e}");
912        }
913
914        let set_client_result = {
915            let cmd_tx = self.cmd_tx.read().await;
916            cmd_tx.send(HandlerCommand::SetClient(client))
917        };
918
919        if let Err(e) = set_client_result {
920            self.handler_tasks.begin_shutdown();
921            self.signal.store(true, Ordering::Release);
922            let handler_abort = self.handler_abort.lock().clone();
923            handler_abort.cancel();
924            let shutdown_result = self.close_stream_task(Duration::from_secs(2)).await;
925            self.out_rx = None;
926            anyhow::bail!(match shutdown_result {
927                Ok(()) => format!("Failed to send WebSocket client to handler: {e}"),
928                Err(shutdown_error) => format!(
929                    "Failed to send WebSocket client to handler: {e}; handler shutdown failed: \
930                     {shutdown_error}"
931                ),
932            });
933        }
934
935        if let Some(control) = &self.socket_control {
936            control.register(move || reconnect_handle.request_reconnect());
937        }
938        log::debug!("Sent WebSocket client to handler");
939
940        if self.credential.is_some()
941            && let Err(e) = self.authenticate().await
942        {
943            self.handler_tasks.begin_shutdown();
944            self.request_close().await;
945            let shutdown_result = self.close_stream_task(Duration::from_secs(2)).await;
946
947            if let Some(control) = &self.socket_control {
948                control.deregister();
949            }
950            self.out_rx = None;
951
952            match shutdown_result {
953                Ok(()) => anyhow::bail!("Authentication failed: {e}"),
954                Err(shutdown_error) => anyhow::bail!(
955                    "Authentication failed: {e}; handler shutdown failed: {shutdown_error}"
956                ),
957            }
958        }
959
960        rollback.disarm();
961        Ok(())
962    }
963
964    /// Authenticates the WebSocket session with OKX.
965    async fn authenticate(&self) -> Result<(), Error> {
966        let credential = self.credential.as_ref().ok_or_else(|| {
967            Error::Io(std::io::Error::other(
968                "API credentials not available to authenticate",
969            ))
970        })?;
971
972        let rx = self.auth_tracker.begin();
973
974        let timestamp = SystemTime::now()
975            .duration_since(SystemTime::UNIX_EPOCH)
976            .expect("System time should be after UNIX epoch")
977            .as_secs()
978            .to_string();
979        let signature = credential.sign(&timestamp, "GET", "/users/self/verify", "");
980
981        let auth_message = OKXAuthentication {
982            op: "login",
983            args: vec![OKXAuthenticationArg {
984                api_key: credential.api_key().to_string(),
985                passphrase: credential.api_passphrase().to_string(),
986                timestamp,
987                sign: signature,
988            }],
989        };
990
991        let payload = serde_json::to_string(&auth_message).map_err(|e| {
992            Error::Io(std::io::Error::other(format!(
993                "Failed to serialize auth message: {e}"
994            )))
995        })?;
996
997        self.cmd_tx
998            .read()
999            .await
1000            .send(HandlerCommand::Authenticate { payload })
1001            .map_err(|e| {
1002                Error::Io(std::io::Error::other(format!(
1003                    "Failed to send authenticate command: {e}"
1004                )))
1005            })?;
1006
1007        match self
1008            .auth_tracker
1009            .wait_for_result::<OKXWsError>(Duration::from_secs(self.auth_timeout_secs), rx)
1010            .await
1011        {
1012            Ok(()) => {
1013                log::debug!("WebSocket authenticated");
1014                Ok(())
1015            }
1016            Err(e) => {
1017                log::error!("WebSocket authentication failed: error={e}");
1018                Err(Error::Io(std::io::Error::other(e.to_string())))
1019            }
1020        }
1021    }
1022
1023    /// Provides the internal data stream as a channel-based stream.
1024    ///
1025    /// # Panics
1026    ///
1027    /// This function panics if:
1028    /// - The websocket is not connected.
1029    /// - `stream_data` has already been called somewhere else (stream receiver is then taken).
1030    pub fn stream(&mut self) -> impl Stream<Item = OKXWsMessage> + 'static {
1031        let rx = self
1032            .out_rx
1033            .take()
1034            .expect("Data stream receiver already taken or not connected");
1035        let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
1036        async_stream::stream! {
1037            while let Some(data) = rx.recv().await {
1038                yield data;
1039            }
1040        }
1041    }
1042
1043    /// Wait until the WebSocket connection is active.
1044    ///
1045    /// # Errors
1046    ///
1047    /// Returns an error if the connection times out.
1048    pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), OKXWsError> {
1049        let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
1050
1051        tokio::time::timeout(timeout, async {
1052            while !self.is_active() {
1053                tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1054            }
1055        })
1056        .await
1057        .map_err(|_| {
1058            OKXWsError::ClientError(format!(
1059                "WebSocket connection timeout after {timeout_secs} seconds"
1060            ))
1061        })?;
1062
1063        Ok(())
1064    }
1065
1066    pub(crate) fn begin_shutdown(&self) {
1067        self.handler_tasks.begin_shutdown();
1068        self.signal.store(true, Ordering::Release);
1069
1070        let handler_abort = self.handler_abort.lock().clone();
1071        handler_abort.cancel();
1072    }
1073
1074    /// Signals the handler to close without joining its task.
1075    pub(crate) async fn request_close(&self) {
1076        self.signal.store(true, Ordering::Release);
1077
1078        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
1079            log::debug!("Handler channel closed before disconnect command was sent: {e}");
1080        } else {
1081            log::debug!("Sent disconnect command to handler");
1082        }
1083    }
1084
1085    /// Closes the client.
1086    ///
1087    /// # Errors
1088    ///
1089    /// Returns an error if disconnecting the websocket or cleaning up the
1090    /// client fails.
1091    pub async fn close(&mut self) -> Result<(), Error> {
1092        let connect_lock = Arc::clone(&self.connect_lock);
1093        let _connect_guard = connect_lock.lock().await;
1094
1095        self.close_locked().await
1096    }
1097
1098    async fn close_locked(&self) -> Result<(), Error> {
1099        log::debug!("Starting close process");
1100
1101        self.handler_tasks.begin_shutdown();
1102        self.request_close().await;
1103
1104        let task_result = self.close_stream_task(Duration::from_secs(2)).await;
1105
1106        // Wipe per-base-pair refcounts so a subsequent reconnect can re-arm
1107        // the index-tickers channel. Otherwise the stale count short-circuits
1108        // every future `subscribe_index_prices` call and the feed stays dark.
1109        self.index_pair_subscribers.clear();
1110
1111        if let Some(control) = &self.socket_control {
1112            control.deregister();
1113        }
1114
1115        log::debug!("Close process completed");
1116
1117        task_result
1118    }
1119
1120    async fn close_stream_task(&self, timeout: Duration) -> Result<(), Error> {
1121        match self.handler_tasks.finish_shutdown(timeout, timeout).await {
1122            Ok(()) => Ok(()),
1123            Err(error @ TaskShutdownError::Timeout { .. }) => Err(Error::Io(std::io::Error::new(
1124                std::io::ErrorKind::TimedOut,
1125                format!("Timed out joining WebSocket handler task after abort: {error}"),
1126            ))),
1127            Err(e) => Err(Error::Io(std::io::Error::other(format!(
1128                "WebSocket handler shutdown failed: {e}"
1129            )))),
1130        }
1131    }
1132
1133    /// Get active subscriptions for a specific instrument.
1134    pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<OKXWsChannel> {
1135        let symbol = instrument_id.symbol.inner();
1136        let mut channels = Vec::new();
1137
1138        for entry in self.subscriptions_inst_id.iter() {
1139            let (channel, instruments) = entry.pair();
1140            if instruments.contains(&symbol) {
1141                channels.push(channel.clone());
1142            }
1143        }
1144
1145        channels
1146    }
1147
1148    fn generate_unique_request_id(&self) -> String {
1149        self.request_id_counter
1150            .fetch_add(1, Ordering::SeqCst)
1151            .to_string()
1152    }
1153
1154    async fn subscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1155        // Send the command first; only update local state on success
1156        self.cmd_tx
1157            .read()
1158            .await
1159            .send(HandlerCommand::Subscribe { args: args.clone() })
1160            .map_err(|e| {
1161                OKXWsError::ClientError(format!("Failed to send subscribe command: {e}"))
1162            })?;
1163
1164        for arg in &args {
1165            let topic = topic_from_subscription_arg(arg);
1166            self.subscriptions_state.mark_subscribe(&topic);
1167
1168            // Check if this is a bare channel (no inst params)
1169            if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1170                self.subscriptions_bare.insert(arg.channel.clone(), true);
1171            } else {
1172                if let Some(inst_type) = &arg.inst_type {
1173                    self.subscriptions_inst_type
1174                        .entry(arg.channel.clone())
1175                        .or_default()
1176                        .insert(*inst_type);
1177                }
1178
1179                if let Some(inst_family) = &arg.inst_family {
1180                    self.subscriptions_inst_family
1181                        .entry(arg.channel.clone())
1182                        .or_default()
1183                        .insert(*inst_family);
1184                }
1185
1186                if let Some(inst_id) = &arg.inst_id {
1187                    self.subscriptions_inst_id
1188                        .entry(arg.channel.clone())
1189                        .or_default()
1190                        .insert(*inst_id);
1191                }
1192            }
1193        }
1194
1195        Ok(())
1196    }
1197
1198    #[expect(clippy::collapsible_if)]
1199    async fn unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> Result<(), OKXWsError> {
1200        // Send the command first; only update local state on success
1201        self.cmd_tx
1202            .read()
1203            .await
1204            .send(HandlerCommand::Unsubscribe { args: args.clone() })
1205            .map_err(|e| {
1206                OKXWsError::ClientError(format!("Failed to send unsubscribe command: {e}"))
1207            })?;
1208
1209        for arg in &args {
1210            let topic = topic_from_subscription_arg(arg);
1211            self.subscriptions_state.mark_unsubscribe(&topic);
1212
1213            if arg.inst_type.is_none() && arg.inst_family.is_none() && arg.inst_id.is_none() {
1214                self.subscriptions_bare.remove(&arg.channel);
1215            } else {
1216                if let Some(inst_type) = &arg.inst_type {
1217                    if let Some(mut entry) = self.subscriptions_inst_type.get_mut(&arg.channel) {
1218                        entry.remove(inst_type);
1219                        if entry.is_empty() {
1220                            drop(entry);
1221                            self.subscriptions_inst_type.remove(&arg.channel);
1222                        }
1223                    }
1224                }
1225
1226                if let Some(inst_family) = &arg.inst_family {
1227                    if let Some(mut entry) = self.subscriptions_inst_family.get_mut(&arg.channel) {
1228                        entry.remove(inst_family);
1229                        if entry.is_empty() {
1230                            drop(entry);
1231                            self.subscriptions_inst_family.remove(&arg.channel);
1232                        }
1233                    }
1234                }
1235
1236                if let Some(inst_id) = &arg.inst_id {
1237                    if let Some(mut entry) = self.subscriptions_inst_id.get_mut(&arg.channel) {
1238                        entry.remove(inst_id);
1239                        if entry.is_empty() {
1240                            drop(entry);
1241                            self.subscriptions_inst_id.remove(&arg.channel);
1242                        }
1243                    }
1244                }
1245            }
1246        }
1247
1248        Ok(())
1249    }
1250
1251    async fn subscribe_inst_id(
1252        &self,
1253        channel: OKXWsChannel,
1254        inst_id: Ustr,
1255    ) -> Result<(), OKXWsError> {
1256        self.subscribe(vec![OKXSubscriptionArg {
1257            channel,
1258            inst_type: None,
1259            inst_family: None,
1260            inst_id: Some(inst_id),
1261        }])
1262        .await
1263    }
1264
1265    async fn unsubscribe_inst_id(
1266        &self,
1267        channel: OKXWsChannel,
1268        inst_id: Ustr,
1269    ) -> Result<(), OKXWsError> {
1270        self.unsubscribe(vec![OKXSubscriptionArg {
1271            channel,
1272            inst_type: None,
1273            inst_family: None,
1274            inst_id: Some(inst_id),
1275        }])
1276        .await
1277    }
1278
1279    /// Unsubscribes from all active subscriptions in batched messages.
1280    ///
1281    /// Collects all confirmed subscriptions and sends unsubscribe requests in batches,
1282    /// which is significantly more efficient than individual unsubscribes during disconnect.
1283    ///
1284    /// # Errors
1285    ///
1286    /// Returns an error if the unsubscribe request fails to send.
1287    pub async fn unsubscribe_all(&self) -> Result<(), OKXWsError> {
1288        const BATCH_SIZE: usize = 256;
1289
1290        let mut all_args = Vec::new();
1291
1292        for entry in self.subscriptions_inst_type.iter() {
1293            let (channel, inst_types) = entry.pair();
1294            for inst_type in inst_types {
1295                all_args.push(OKXSubscriptionArg {
1296                    channel: channel.clone(),
1297                    inst_type: Some(*inst_type),
1298                    inst_family: None,
1299                    inst_id: None,
1300                });
1301            }
1302        }
1303
1304        for entry in self.subscriptions_inst_family.iter() {
1305            let (channel, inst_families) = entry.pair();
1306            for inst_family in inst_families {
1307                all_args.push(OKXSubscriptionArg {
1308                    channel: channel.clone(),
1309                    inst_type: None,
1310                    inst_family: Some(*inst_family),
1311                    inst_id: None,
1312                });
1313            }
1314        }
1315
1316        for entry in self.subscriptions_inst_id.iter() {
1317            let (channel, inst_ids) = entry.pair();
1318            for inst_id in inst_ids {
1319                all_args.push(OKXSubscriptionArg {
1320                    channel: channel.clone(),
1321                    inst_type: None,
1322                    inst_family: None,
1323                    inst_id: Some(*inst_id),
1324                });
1325            }
1326        }
1327
1328        for entry in self.subscriptions_bare.iter() {
1329            let channel = entry.key();
1330            all_args.push(OKXSubscriptionArg {
1331                channel: channel.clone(),
1332                inst_type: None,
1333                inst_family: None,
1334                inst_id: None,
1335            });
1336        }
1337
1338        if all_args.is_empty() {
1339            log::debug!("No active subscriptions to unsubscribe from");
1340            return Ok(());
1341        }
1342
1343        log::debug!("Batched unsubscribe from {} channels", all_args.len());
1344
1345        for chunk in all_args.chunks(BATCH_SIZE) {
1346            self.unsubscribe(chunk.to_vec()).await?;
1347        }
1348
1349        // The index-pair refcount mirrors live subscriptions; after a bulk
1350        // unsubscribe the venue knows nothing, so any retained count would
1351        // wedge the next `subscribe_index_prices`.
1352        self.index_pair_subscribers.clear();
1353
1354        Ok(())
1355    }
1356
1357    /// Subscribes to instrument updates for a specific instrument type.
1358    ///
1359    /// Provides updates when instrument specifications change.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns an error if the subscription request fails.
1364    ///
1365    /// # References
1366    ///
1367    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-instruments-channel>.
1368    pub async fn subscribe_instruments(
1369        &self,
1370        instrument_type: OKXInstrumentType,
1371    ) -> Result<(), OKXWsError> {
1372        let arg = OKXSubscriptionArg {
1373            channel: OKXWsChannel::Instruments,
1374            inst_type: Some(instrument_type),
1375            inst_family: None,
1376            inst_id: None,
1377        };
1378        self.subscribe(vec![arg]).await
1379    }
1380
1381    /// Subscribes to instrument updates for a specific instrument.
1382    ///
1383    /// Since OKX doesn't support subscribing to individual instruments via `instId`,
1384    /// this method subscribes to the entire instrument type. OKX handles duplicate
1385    /// subscriptions gracefully and pushes a fresh snapshot on each subscribe.
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns an error if the subscription request fails.
1390    ///
1391    /// # References
1392    ///
1393    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-instruments-channel>.
1394    pub async fn subscribe_instrument(
1395        &self,
1396        instrument_id: InstrumentId,
1397    ) -> Result<(), OKXWsError> {
1398        let inst_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
1399        log::debug!("Subscribing to instrument type {inst_type:?} for {instrument_id}");
1400        self.subscribe_instruments(inst_type).await
1401    }
1402
1403    /// Subscribes to order book data for an instrument.
1404    ///
1405    /// This is a convenience method that calls [`Self::subscribe_book_with_depth`] with depth 0,
1406    /// which automatically selects the appropriate channel based on VIP level.
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns an error if the subscription request fails.
1411    pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1412        self.subscribe_book_with_depth(instrument_id, 0).await
1413    }
1414
1415    /// Subscribes to the standard books channel (internal method).
1416    pub(crate) async fn subscribe_books_channel(
1417        &self,
1418        instrument_id: InstrumentId,
1419    ) -> Result<(), OKXWsError> {
1420        self.subscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1421            .await
1422    }
1423
1424    /// Subscribes to the Retail Price Improvement order book channel.
1425    ///
1426    /// # Errors
1427    ///
1428    /// Returns an error if the subscription request fails.
1429    pub async fn subscribe_book_rpi(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1430        self.subscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1431            .await
1432    }
1433
1434    /// Requests a fresh snapshot by replacing the current incremental book subscription.
1435    pub(crate) async fn resubscribe_book_channel(
1436        &self,
1437        instrument_id: InstrumentId,
1438        channel: OKXBookChannel,
1439    ) -> Result<(), OKXWsError> {
1440        let channel = ws_channel_for_book(channel);
1441        self.resubscribe_ws_channel(instrument_id, channel).await
1442    }
1443
1444    /// Replaces an instrument subscription on the specified WebSocket channel.
1445    pub(crate) async fn resubscribe_ws_channel(
1446        &self,
1447        instrument_id: InstrumentId,
1448        channel: OKXWsChannel,
1449    ) -> Result<(), OKXWsError> {
1450        self.unsubscribe_inst_id(channel.clone(), instrument_id.symbol.inner())
1451            .await?;
1452        self.subscribe_inst_id(channel, instrument_id.symbol.inner())
1453            .await
1454    }
1455
1456    /// Subscribes to 5-level order book snapshot data for an instrument.
1457    ///
1458    /// Updates every 100ms when there are changes.
1459    ///
1460    /// # Errors
1461    ///
1462    /// Returns an error if the subscription request fails.
1463    ///
1464    /// # References
1465    ///
1466    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-order-book-5-depth-channel>.
1467    pub async fn subscribe_book_depth5(
1468        &self,
1469        instrument_id: InstrumentId,
1470    ) -> Result<(), OKXWsError> {
1471        self.subscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1472            .await
1473    }
1474
1475    /// Subscribes to 50-level tick-by-tick order book data for an instrument.
1476    ///
1477    /// Provides real-time updates whenever order book changes.
1478    ///
1479    /// # Errors
1480    ///
1481    /// Returns an error if the subscription request fails.
1482    ///
1483    /// # References
1484    ///
1485    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-order-book-50-depth-tbt-channel>.
1486    pub async fn subscribe_book50_l2_tbt(
1487        &self,
1488        instrument_id: InstrumentId,
1489    ) -> Result<(), OKXWsError> {
1490        self.subscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1491            .await
1492    }
1493
1494    /// Subscribes to tick-by-tick full depth (400 levels) order book data for an instrument.
1495    ///
1496    /// Provides real-time updates with all depth levels whenever order book changes.
1497    ///
1498    /// # Errors
1499    ///
1500    /// Returns an error if the subscription request fails.
1501    ///
1502    /// # References
1503    ///
1504    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-order-book-400-depth-tbt-channel>.
1505    pub async fn subscribe_book_l2_tbt(
1506        &self,
1507        instrument_id: InstrumentId,
1508    ) -> Result<(), OKXWsError> {
1509        self.subscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1510            .await
1511    }
1512
1513    /// Subscribes to order book data with automatic channel selection based on VIP level and depth.
1514    ///
1515    /// Selects the optimal channel based on user's VIP tier and requested depth:
1516    /// - depth 50: Requires VIP4+, subscribes to `books50-l2-tbt`
1517    /// - depth 0 or 400:
1518    ///   - VIP5+: subscribes to `books-l2-tbt` (400 depth, fastest)
1519    ///   - Below VIP5: subscribes to `books` (standard depth)
1520    ///
1521    /// # Errors
1522    ///
1523    /// Returns an error if:
1524    /// - Subscription request fails
1525    /// - depth is 50 but VIP level is below 4
1526    pub async fn subscribe_book_with_depth(
1527        &self,
1528        instrument_id: InstrumentId,
1529        depth: u16,
1530    ) -> anyhow::Result<()> {
1531        let vip = self.vip_level();
1532
1533        if !matches!(depth, 0 | 50 | 400) {
1534            anyhow::bail!("Invalid depth {depth}, must be 0, 50, or 400");
1535        }
1536
1537        if depth == 50 && vip < OKXVipLevel::Vip4 {
1538            anyhow::bail!("VIP level {vip} insufficient for 50 depth subscription (requires VIP4)");
1539        }
1540
1541        let channel = select_book_channel(depth as usize, vip);
1542        self.subscribe_inst_id(ws_channel_for_book(channel), instrument_id.symbol.inner())
1543            .await?;
1544        Ok(())
1545    }
1546
1547    /// Subscribes to best bid/ask quote data for an instrument.
1548    ///
1549    /// Provides tick-by-tick updates of the best bid and ask prices using the bbo-tbt channel.
1550    /// Supports all instrument types: SPOT, MARGIN, SWAP, FUTURES, OPTION.
1551    ///
1552    /// # Errors
1553    ///
1554    /// Returns an error if the subscription request fails.
1555    ///
1556    /// # References
1557    ///
1558    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-best-bid-offer-channel>.
1559    pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1560        self.subscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1561            .await
1562    }
1563
1564    /// Subscribes to trade data for an instrument.
1565    ///
1566    /// When `aggregated` is `false`, subscribes to the `trades` channel (per-match updates).
1567    /// When `aggregated` is `true`, subscribes to the `trades-all` channel (aggregated updates).
1568    ///
1569    /// # Errors
1570    ///
1571    /// Returns an error if the subscription request fails.
1572    ///
1573    /// # References
1574    ///
1575    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-trades-channel>.
1576    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-all-trades-channel>.
1577    pub async fn subscribe_trades(
1578        &self,
1579        instrument_id: InstrumentId,
1580        aggregated: bool,
1581    ) -> Result<(), OKXWsError> {
1582        let channel = if aggregated {
1583            OKXWsChannel::TradesAll
1584        } else {
1585            OKXWsChannel::Trades
1586        };
1587        self.subscribe_inst_id(channel, instrument_id.symbol.inner())
1588            .await
1589    }
1590
1591    /// Subscribes to 24hr rolling ticker data for an instrument.
1592    ///
1593    /// Updates every 100ms with trading statistics.
1594    ///
1595    /// # Errors
1596    ///
1597    /// Returns an error if the subscription request fails.
1598    ///
1599    /// # References
1600    ///
1601    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-tickers-channel>.
1602    pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1603        self.subscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1604            .await
1605    }
1606
1607    /// Subscribes to mark price data for derivatives instruments.
1608    ///
1609    /// Updates every 200ms for perpetual swaps, or at settlement for futures.
1610    ///
1611    /// # Errors
1612    ///
1613    /// Returns an error if the subscription request fails.
1614    ///
1615    /// # References
1616    ///
1617    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-mark-price-channel>.
1618    pub async fn subscribe_mark_prices(
1619        &self,
1620        instrument_id: InstrumentId,
1621    ) -> Result<(), OKXWsError> {
1622        self.subscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1623            .await
1624    }
1625
1626    /// Subscribes to index price data for an instrument.
1627    ///
1628    /// Updates every second with the underlying index price.
1629    ///
1630    /// # Errors
1631    ///
1632    /// Returns an error if the subscription request fails.
1633    ///
1634    /// # References
1635    ///
1636    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-index-tickers-channel>.
1637    pub async fn subscribe_index_prices(
1638        &self,
1639        instrument_id: InstrumentId,
1640    ) -> Result<(), OKXWsError> {
1641        // Index-tickers channel requires base pair format (e.g., BTC-USDT)
1642        let symbol = instrument_id.symbol.inner();
1643        let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1644            .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1645        let base_pair = Ustr::from(&format!("{base}-{quote}"));
1646
1647        // Hold the transition lock across both the refcount update and the
1648        // venue send so a concurrent `unsubscribe_index_prices` cannot
1649        // observe a transient 0 state between our decrement and the venue
1650        // unsubscribe, or vice versa. Without this, contract rolls can
1651        // leave the venue unsubscribed while the local count says active.
1652        let _guard = self.index_pair_transition.lock().await;
1653
1654        // Bump the per-base-pair refcount so a later unsubscribe can decide
1655        // whether it is the last subscriber. Only the 0→1 transition fires
1656        // a venue subscribe; subsequent callers piggy-back on the existing
1657        // channel.
1658        let is_first = {
1659            let mut count = self.index_pair_subscribers.entry(base_pair).or_insert(0);
1660            *count += 1;
1661            *count == 1
1662        };
1663
1664        if !is_first {
1665            return Ok(());
1666        }
1667
1668        let arg = OKXSubscriptionArg {
1669            channel: OKXWsChannel::IndexTickers,
1670            inst_type: None,
1671            inst_family: None,
1672            inst_id: Some(base_pair),
1673        };
1674
1675        match self.subscribe(vec![arg]).await {
1676            Ok(()) => Ok(()),
1677            Err(e) => {
1678                // When the venue subscribe fails there is no live channel,
1679                // even though other local callers may have piggy-backed on
1680                // the in-flight attempt (they saw `!is_first` and returned
1681                // `Ok`). Removing the entry entirely ensures the next
1682                // caller re-enters the 0→1 branch and re-arms the venue
1683                // subscription; a mere decrement would leave the map at 1+
1684                // without a matching feed and every later subscribe would
1685                // short-circuit into a silent no-op.
1686                self.index_pair_subscribers.remove(&base_pair);
1687                Err(e)
1688            }
1689        }
1690    }
1691
1692    /// Subscribes to option summary data for an instrument family.
1693    ///
1694    /// Streams greeks (delta, gamma, vega, theta), implied volatility, and other
1695    /// option metrics for all instruments in the specified family.
1696    ///
1697    /// # Errors
1698    ///
1699    /// Returns an error if the subscription request fails.
1700    ///
1701    /// # References
1702    ///
1703    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-option-summary-channel>.
1704    pub async fn subscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1705        let arg = OKXSubscriptionArg {
1706            channel: OKXWsChannel::OptionSummary,
1707            inst_type: None,
1708            inst_family: Some(inst_family),
1709            inst_id: None,
1710        };
1711        self.subscribe(vec![arg]).await
1712    }
1713
1714    /// Subscribes to event contract market updates.
1715    ///
1716    /// # Errors
1717    ///
1718    /// Returns an error if the subscription request fails.
1719    ///
1720    /// # References
1721    ///
1722    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-event-contract-markets-channel>.
1723    pub async fn subscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
1724        let arg = OKXSubscriptionArg {
1725            channel: OKXWsChannel::EventContractMarkets,
1726            inst_type: Some(OKXInstrumentType::Events),
1727            inst_family: None,
1728            inst_id: None,
1729        };
1730        self.subscribe(vec![arg]).await
1731    }
1732
1733    /// Returns a reference to the option greeks subscription map.
1734    ///
1735    /// The map stores the set of greeks conventions to emit for each subscribed instrument.
1736    pub fn option_greeks_subs(&self) -> &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>> {
1737        &self.option_greeks_subs
1738    }
1739
1740    /// Adds an instrument to the option greeks subscription filter, emitting both
1741    /// Black-Scholes and price-adjusted greeks.
1742    pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1743        let both: AHashSet<OKXGreeksType> =
1744            [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
1745        self.option_greeks_subs.insert(instrument_id, both);
1746    }
1747
1748    /// Adds an instrument to the option greeks subscription filter with an explicit
1749    /// set of greeks conventions to emit. An empty set is treated as "emit both".
1750    pub fn add_option_greeks_sub_with_conventions(
1751        &self,
1752        instrument_id: InstrumentId,
1753        conventions: AHashSet<OKXGreeksType>,
1754    ) {
1755        let set = if conventions.is_empty() {
1756            [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
1757        } else {
1758            conventions
1759        };
1760        self.option_greeks_subs.insert(instrument_id, set);
1761    }
1762
1763    /// Removes an instrument from the option greeks subscription filter.
1764    pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1765        self.option_greeks_subs.remove(instrument_id);
1766    }
1767
1768    /// Subscribes to funding rate data for perpetual swap instruments.
1769    ///
1770    /// Updates when funding rate changes or at funding intervals.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if the subscription request fails.
1775    ///
1776    /// # References
1777    ///
1778    /// <https://www.okx.com/docs-v5/en/#public-data-websocket-funding-rate-channel>.
1779    pub async fn subscribe_funding_rates(
1780        &self,
1781        instrument_id: InstrumentId,
1782    ) -> Result<(), OKXWsError> {
1783        self.subscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
1784            .await
1785    }
1786
1787    /// Subscribes to candlestick/bar data for an instrument.
1788    ///
1789    /// Supports various time intervals from 1s to 3M.
1790    ///
1791    /// # Errors
1792    ///
1793    /// Returns an error if the subscription request fails.
1794    ///
1795    /// # References
1796    ///
1797    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-ws-candlesticks-channel>.
1798    pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
1799        // Use regular trade-price candlesticks which work for all instrument types
1800        let channel = bar_spec_as_okx_channel(bar_type.spec())
1801            .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1802        self.subscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
1803            .await
1804    }
1805
1806    /// Unsubscribes from instrument updates for a specific instrument type.
1807    ///
1808    /// # Errors
1809    ///
1810    /// Returns an error if the subscription request fails.
1811    pub async fn unsubscribe_instruments(
1812        &self,
1813        instrument_type: OKXInstrumentType,
1814    ) -> Result<(), OKXWsError> {
1815        let arg = OKXSubscriptionArg {
1816            channel: OKXWsChannel::Instruments,
1817            inst_type: Some(instrument_type),
1818            inst_family: None,
1819            inst_id: None,
1820        };
1821        self.unsubscribe(vec![arg]).await
1822    }
1823
1824    /// Unsubscribe from instrument updates for a specific instrument.
1825    ///
1826    /// No-op: the instruments channel is per-type (SWAP, FUTURES, etc.) and
1827    /// other instruments of the same type may still need it. The channel
1828    /// stays subscribed; overhead is negligible.
1829    ///
1830    /// # Errors
1831    ///
1832    /// Returns an error if the unsubscription request fails.
1833    pub async fn unsubscribe_instrument(
1834        &self,
1835        instrument_id: InstrumentId,
1836    ) -> Result<(), OKXWsError> {
1837        log::debug!("Instrument unsubscribe is a no-op (shared per-type channel): {instrument_id}");
1838        Ok(())
1839    }
1840
1841    /// Unsubscribe from full order book data for an instrument.
1842    ///
1843    /// # Errors
1844    ///
1845    /// Returns an error if the subscription request fails.
1846    pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1847        self.unsubscribe_inst_id(OKXWsChannel::Books, instrument_id.symbol.inner())
1848            .await
1849    }
1850
1851    /// Unsubscribe from Retail Price Improvement order book data for an instrument.
1852    ///
1853    /// # Errors
1854    ///
1855    /// Returns an error if the unsubscription request fails.
1856    pub async fn unsubscribe_book_rpi(
1857        &self,
1858        instrument_id: InstrumentId,
1859    ) -> Result<(), OKXWsError> {
1860        self.unsubscribe_inst_id(OKXWsChannel::BooksRpi, instrument_id.symbol.inner())
1861            .await
1862    }
1863
1864    /// Unsubscribe from 5-level order book snapshot data for an instrument.
1865    ///
1866    /// # Errors
1867    ///
1868    /// Returns an error if the subscription request fails.
1869    pub async fn unsubscribe_book_depth5(
1870        &self,
1871        instrument_id: InstrumentId,
1872    ) -> Result<(), OKXWsError> {
1873        self.unsubscribe_inst_id(OKXWsChannel::Books5, instrument_id.symbol.inner())
1874            .await
1875    }
1876
1877    /// Unsubscribe from 50-level tick-by-tick order book data for an instrument.
1878    ///
1879    /// # Errors
1880    ///
1881    /// Returns an error if the subscription request fails.
1882    pub async fn unsubscribe_book50_l2_tbt(
1883        &self,
1884        instrument_id: InstrumentId,
1885    ) -> Result<(), OKXWsError> {
1886        self.unsubscribe_inst_id(OKXWsChannel::Books50Tbt, instrument_id.symbol.inner())
1887            .await
1888    }
1889
1890    /// Unsubscribe from tick-by-tick full depth order book data for an instrument.
1891    ///
1892    /// # Errors
1893    ///
1894    /// Returns an error if the subscription request fails.
1895    pub async fn unsubscribe_book_l2_tbt(
1896        &self,
1897        instrument_id: InstrumentId,
1898    ) -> Result<(), OKXWsError> {
1899        self.unsubscribe_inst_id(OKXWsChannel::BooksTbt, instrument_id.symbol.inner())
1900            .await
1901    }
1902
1903    /// Unsubscribe from best bid/ask quote data for an instrument.
1904    ///
1905    /// # Errors
1906    ///
1907    /// Returns an error if the subscription request fails.
1908    pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1909        self.unsubscribe_inst_id(OKXWsChannel::BboTbt, instrument_id.symbol.inner())
1910            .await
1911    }
1912
1913    /// Unsubscribe from 24hr rolling ticker data for an instrument.
1914    ///
1915    /// # Errors
1916    ///
1917    /// Returns an error if the subscription request fails.
1918    pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
1919        self.unsubscribe_inst_id(OKXWsChannel::Tickers, instrument_id.symbol.inner())
1920            .await
1921    }
1922
1923    /// Unsubscribe from mark price data for a derivatives instrument.
1924    ///
1925    /// # Errors
1926    ///
1927    /// Returns an error if the subscription request fails.
1928    pub async fn unsubscribe_mark_prices(
1929        &self,
1930        instrument_id: InstrumentId,
1931    ) -> Result<(), OKXWsError> {
1932        self.unsubscribe_inst_id(OKXWsChannel::MarkPrice, instrument_id.symbol.inner())
1933            .await
1934    }
1935
1936    /// Unsubscribe from index price data for the base pair derived from
1937    /// `instrument_id`.
1938    ///
1939    /// Refcounting is handled internally so any caller (Rust data client,
1940    /// Python wrapper, etc.) can pair every `subscribe_index_prices` with
1941    /// exactly one `unsubscribe_index_prices`. The OKX `index-tickers`
1942    /// channel is keyed by base pair (e.g. `BTC-USDT`), so the venue
1943    /// unsubscribe only fires when the last subscriber for that pair drops.
1944    ///
1945    /// # Errors
1946    ///
1947    /// Returns an error if the unsubscription request fails.
1948    pub async fn unsubscribe_index_prices(
1949        &self,
1950        instrument_id: InstrumentId,
1951    ) -> Result<(), OKXWsError> {
1952        let symbol = instrument_id.symbol.inner();
1953        let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())
1954            .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
1955        let base_pair = Ustr::from(&format!("{base}-{quote}"));
1956
1957        // Serialize with any concurrent `subscribe_index_prices` on the same
1958        // base pair. See the subscribe path for the race this prevents.
1959        let _guard = self.index_pair_transition.lock().await;
1960
1961        let is_last = {
1962            let Some(mut count) = self.index_pair_subscribers.get_mut(&base_pair) else {
1963                // No matching subscriber recorded; nothing to do.
1964                return Ok(());
1965            };
1966            *count = count.saturating_sub(1);
1967            *count == 0
1968        };
1969
1970        if !is_last {
1971            return Ok(());
1972        }
1973
1974        self.index_pair_subscribers
1975            .remove_if(&base_pair, |_, count| *count == 0);
1976
1977        let arg = OKXSubscriptionArg {
1978            channel: OKXWsChannel::IndexTickers,
1979            inst_type: None,
1980            inst_family: None,
1981            inst_id: Some(base_pair),
1982        };
1983        self.unsubscribe(vec![arg]).await
1984    }
1985
1986    /// Unsubscribe from option summary data for an instrument family.
1987    ///
1988    /// # Errors
1989    ///
1990    /// Returns an error if the unsubscription request fails.
1991    pub async fn unsubscribe_option_summary(&self, inst_family: Ustr) -> Result<(), OKXWsError> {
1992        let arg = OKXSubscriptionArg {
1993            channel: OKXWsChannel::OptionSummary,
1994            inst_type: None,
1995            inst_family: Some(inst_family),
1996            inst_id: None,
1997        };
1998        self.unsubscribe(vec![arg]).await
1999    }
2000
2001    /// Unsubscribes from event contract market updates.
2002    ///
2003    /// # Errors
2004    ///
2005    /// Returns an error if the unsubscription request fails.
2006    pub async fn unsubscribe_event_contract_markets(&self) -> Result<(), OKXWsError> {
2007        let arg = OKXSubscriptionArg {
2008            channel: OKXWsChannel::EventContractMarkets,
2009            inst_type: Some(OKXInstrumentType::Events),
2010            inst_family: None,
2011            inst_id: None,
2012        };
2013        self.unsubscribe(vec![arg]).await
2014    }
2015
2016    /// Unsubscribe from funding rate data for a perpetual swap instrument.
2017    ///
2018    /// # Errors
2019    ///
2020    /// Returns an error if the subscription request fails.
2021    pub async fn unsubscribe_funding_rates(
2022        &self,
2023        instrument_id: InstrumentId,
2024    ) -> Result<(), OKXWsError> {
2025        self.unsubscribe_inst_id(OKXWsChannel::FundingRate, instrument_id.symbol.inner())
2026            .await
2027    }
2028
2029    /// Unsubscribe from trade data for an instrument.
2030    ///
2031    /// # Errors
2032    ///
2033    /// Returns an error if the subscription request fails.
2034    pub async fn unsubscribe_trades(
2035        &self,
2036        instrument_id: InstrumentId,
2037        aggregated: bool,
2038    ) -> Result<(), OKXWsError> {
2039        let channel = if aggregated {
2040            OKXWsChannel::TradesAll
2041        } else {
2042            OKXWsChannel::Trades
2043        };
2044        self.unsubscribe_inst_id(channel, instrument_id.symbol.inner())
2045            .await
2046    }
2047
2048    /// Unsubscribe from candlestick/bar data for an instrument.
2049    ///
2050    /// # Errors
2051    ///
2052    /// Returns an error if the subscription request fails.
2053    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), OKXWsError> {
2054        let channel = bar_spec_as_okx_channel(bar_type.spec())
2055            .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2056        self.unsubscribe_inst_id(channel, bar_type.instrument_id().symbol.inner())
2057            .await
2058    }
2059
2060    /// Subscribes to order updates for the given instrument type.
2061    ///
2062    /// # Errors
2063    ///
2064    /// Returns an error if the subscription request fails.
2065    pub async fn subscribe_orders(
2066        &self,
2067        instrument_type: OKXInstrumentType,
2068    ) -> Result<(), OKXWsError> {
2069        let arg = OKXSubscriptionArg {
2070            channel: OKXWsChannel::Orders,
2071            inst_type: Some(instrument_type),
2072            inst_family: None,
2073            inst_id: None,
2074        };
2075        self.subscribe(vec![arg]).await
2076    }
2077
2078    /// Unsubscribes from order updates for the given instrument type.
2079    ///
2080    /// # Errors
2081    ///
2082    /// Returns an error if the subscription request fails.
2083    pub async fn unsubscribe_orders(
2084        &self,
2085        instrument_type: OKXInstrumentType,
2086    ) -> Result<(), OKXWsError> {
2087        let arg = OKXSubscriptionArg {
2088            channel: OKXWsChannel::Orders,
2089            inst_type: Some(instrument_type),
2090            inst_family: None,
2091            inst_id: None,
2092        };
2093        self.unsubscribe(vec![arg]).await
2094    }
2095
2096    /// Subscribes to Nitro spread order updates.
2097    ///
2098    /// # Errors
2099    ///
2100    /// Returns an error if the subscription request fails.
2101    pub async fn subscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2102        let arg = OKXSubscriptionArg {
2103            channel: OKXWsChannel::SprdOrders,
2104            inst_type: None,
2105            inst_family: None,
2106            inst_id: None,
2107        };
2108        self.subscribe(vec![arg]).await
2109    }
2110
2111    /// Unsubscribes from Nitro spread order updates.
2112    ///
2113    /// # Errors
2114    ///
2115    /// Returns an error if the subscription request fails.
2116    pub async fn unsubscribe_spread_orders(&self) -> Result<(), OKXWsError> {
2117        let arg = OKXSubscriptionArg {
2118            channel: OKXWsChannel::SprdOrders,
2119            inst_type: None,
2120            inst_family: None,
2121            inst_id: None,
2122        };
2123        self.unsubscribe(vec![arg]).await
2124    }
2125
2126    /// Subscribes to best bid/offer quotes for a spread instrument (`sprd-bbo-tbt`).
2127    ///
2128    /// # Errors
2129    ///
2130    /// Returns an error if the subscription request fails.
2131    pub async fn subscribe_spread_quotes(
2132        &self,
2133        instrument_id: InstrumentId,
2134    ) -> Result<(), OKXWsError> {
2135        self.subscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2136            .await
2137    }
2138
2139    /// Subscribes to 5-level book snapshots for a spread instrument (`sprd-books5`).
2140    ///
2141    /// # Errors
2142    ///
2143    /// Returns an error if the subscription request fails.
2144    pub async fn subscribe_spread_book(
2145        &self,
2146        instrument_id: InstrumentId,
2147    ) -> Result<(), OKXWsError> {
2148        self.subscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2149            .await
2150    }
2151
2152    /// Subscribes to public trades for a spread instrument (`sprd-public-trades`).
2153    ///
2154    /// # Errors
2155    ///
2156    /// Returns an error if the subscription request fails.
2157    pub async fn subscribe_spread_trades(
2158        &self,
2159        instrument_id: InstrumentId,
2160    ) -> Result<(), OKXWsError> {
2161        self.subscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2162            .await
2163    }
2164
2165    /// Unsubscribes from spread quotes (`sprd-bbo-tbt`).
2166    ///
2167    /// # Errors
2168    ///
2169    /// Returns an error if the unsubscribe request fails.
2170    pub async fn unsubscribe_spread_quotes(
2171        &self,
2172        instrument_id: InstrumentId,
2173    ) -> Result<(), OKXWsError> {
2174        self.unsubscribe_inst_id(OKXWsChannel::SprdBboTbt, instrument_id.symbol.inner())
2175            .await
2176    }
2177
2178    /// Unsubscribes from spread book snapshots (`sprd-books5`).
2179    ///
2180    /// # Errors
2181    ///
2182    /// Returns an error if the unsubscribe request fails.
2183    pub async fn unsubscribe_spread_book(
2184        &self,
2185        instrument_id: InstrumentId,
2186    ) -> Result<(), OKXWsError> {
2187        self.unsubscribe_inst_id(OKXWsChannel::SprdBooks5, instrument_id.symbol.inner())
2188            .await
2189    }
2190
2191    /// Unsubscribes from spread public trades (`sprd-public-trades`).
2192    ///
2193    /// # Errors
2194    ///
2195    /// Returns an error if the unsubscribe request fails.
2196    pub async fn unsubscribe_spread_trades(
2197        &self,
2198        instrument_id: InstrumentId,
2199    ) -> Result<(), OKXWsError> {
2200        self.unsubscribe_inst_id(OKXWsChannel::SprdPublicTrades, instrument_id.symbol.inner())
2201            .await
2202    }
2203
2204    /// Subscribes to algo order updates for the given instrument type.
2205    ///
2206    /// # Errors
2207    ///
2208    /// Returns an error if the subscription request fails.
2209    pub async fn subscribe_orders_algo(
2210        &self,
2211        instrument_type: OKXInstrumentType,
2212    ) -> Result<(), OKXWsError> {
2213        let arg = OKXSubscriptionArg {
2214            channel: OKXWsChannel::OrdersAlgo,
2215            inst_type: Some(instrument_type),
2216            inst_family: None,
2217            inst_id: None,
2218        };
2219        self.subscribe(vec![arg]).await
2220    }
2221
2222    /// Unsubscribes from algo order updates for the given instrument type.
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns an error if the subscription request fails.
2227    pub async fn unsubscribe_orders_algo(
2228        &self,
2229        instrument_type: OKXInstrumentType,
2230    ) -> Result<(), OKXWsError> {
2231        let arg = OKXSubscriptionArg {
2232            channel: OKXWsChannel::OrdersAlgo,
2233            inst_type: Some(instrument_type),
2234            inst_family: None,
2235            inst_id: None,
2236        };
2237        self.unsubscribe(vec![arg]).await
2238    }
2239
2240    /// Subscribes to advance algo order updates (trailing stops, iceberg, twap).
2241    ///
2242    /// # Errors
2243    ///
2244    /// Returns an error if the subscription request fails.
2245    pub async fn subscribe_algo_advance(
2246        &self,
2247        instrument_type: OKXInstrumentType,
2248    ) -> Result<(), OKXWsError> {
2249        let arg = OKXSubscriptionArg {
2250            channel: OKXWsChannel::AlgoAdvance,
2251            inst_type: Some(instrument_type),
2252            inst_family: None,
2253            inst_id: None,
2254        };
2255        self.subscribe(vec![arg]).await
2256    }
2257
2258    /// Unsubscribes from advance algo order updates.
2259    ///
2260    /// # Errors
2261    ///
2262    /// Returns an error if the subscription request fails.
2263    pub async fn unsubscribe_algo_advance(
2264        &self,
2265        instrument_type: OKXInstrumentType,
2266    ) -> Result<(), OKXWsError> {
2267        let arg = OKXSubscriptionArg {
2268            channel: OKXWsChannel::AlgoAdvance,
2269            inst_type: Some(instrument_type),
2270            inst_family: None,
2271            inst_id: None,
2272        };
2273        self.unsubscribe(vec![arg]).await
2274    }
2275
2276    /// Subscribes to account balance updates.
2277    ///
2278    /// # Errors
2279    ///
2280    /// Returns an error if the subscription request fails.
2281    pub async fn subscribe_account(&self) -> Result<(), OKXWsError> {
2282        let arg = OKXSubscriptionArg {
2283            channel: OKXWsChannel::Account,
2284            inst_type: None,
2285            inst_family: None,
2286            inst_id: None,
2287        };
2288        self.subscribe(vec![arg]).await
2289    }
2290
2291    /// Unsubscribes from account balance updates.
2292    ///
2293    /// # Errors
2294    ///
2295    /// Returns an error if the subscription request fails.
2296    pub async fn unsubscribe_account(&self) -> Result<(), OKXWsError> {
2297        let arg = OKXSubscriptionArg {
2298            channel: OKXWsChannel::Account,
2299            inst_type: None,
2300            inst_family: None,
2301            inst_id: None,
2302        };
2303        self.unsubscribe(vec![arg]).await
2304    }
2305
2306    /// Subscribes to position updates for a specific instrument type.
2307    ///
2308    /// # Errors
2309    ///
2310    /// Returns an error if the subscription request fails.
2311    ///
2312    /// # References
2313    ///
2314    /// <https://www.okx.com/docs-v5/en/#websocket-api-private-channel-positions-channel>
2315    pub async fn subscribe_positions(
2316        &self,
2317        inst_type: OKXInstrumentType,
2318    ) -> Result<(), OKXWsError> {
2319        let arg = OKXSubscriptionArg {
2320            channel: OKXWsChannel::Positions,
2321            inst_type: Some(inst_type),
2322            inst_family: None,
2323            inst_id: None,
2324        };
2325        self.subscribe(vec![arg]).await
2326    }
2327
2328    /// Unsubscribes from position updates for a specific instrument type.
2329    ///
2330    /// # Errors
2331    ///
2332    /// Returns an error if the subscription request fails.
2333    pub async fn unsubscribe_positions(
2334        &self,
2335        inst_type: OKXInstrumentType,
2336    ) -> Result<(), OKXWsError> {
2337        let arg = OKXSubscriptionArg {
2338            channel: OKXWsChannel::Positions,
2339            inst_type: Some(inst_type),
2340            inst_family: None,
2341            inst_id: None,
2342        };
2343        self.unsubscribe(vec![arg]).await
2344    }
2345
2346    /// Subscribes to liquidation risk warnings for the given instrument type.
2347    ///
2348    /// # Errors
2349    ///
2350    /// Returns an error if the subscription request fails.
2351    ///
2352    /// # References
2353    ///
2354    /// <https://www.okx.com/docs-v5/en/#trading-account-websocket-liquidation-warning-channel>
2355    pub async fn subscribe_liquidation_warning(
2356        &self,
2357        instrument_type: OKXInstrumentType,
2358    ) -> Result<(), OKXWsError> {
2359        let arg = OKXSubscriptionArg {
2360            channel: OKXWsChannel::LiquidationWarning,
2361            inst_type: Some(instrument_type),
2362            inst_family: None,
2363            inst_id: None,
2364        };
2365        self.subscribe(vec![arg]).await
2366    }
2367
2368    /// Unsubscribes from liquidation risk warnings for the given instrument type.
2369    ///
2370    /// # Errors
2371    ///
2372    /// Returns an error if the unsubscription request fails.
2373    pub async fn unsubscribe_liquidation_warning(
2374        &self,
2375        instrument_type: OKXInstrumentType,
2376    ) -> Result<(), OKXWsError> {
2377        let arg = OKXSubscriptionArg {
2378            channel: OKXWsChannel::LiquidationWarning,
2379            inst_type: Some(instrument_type),
2380            inst_family: None,
2381            inst_id: None,
2382        };
2383        self.unsubscribe(vec![arg]).await
2384    }
2385
2386    /// Place multiple orders in a single batch via WebSocket.
2387    ///
2388    /// # References
2389    ///
2390    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-batch-orders>
2391    async fn ws_batch_place_orders(
2392        &self,
2393        args: Vec<Value>,
2394        client_order_ids: Vec<ClientOrderId>,
2395    ) -> Result<(), OKXWsError> {
2396        let request_id = self.generate_unique_request_id();
2397        let request = OKXWsRequest::<Value> {
2398            id: Some(request_id.clone()),
2399            op: super::enums::OKXWsOperation::BatchOrders,
2400            exp_time: None,
2401            args,
2402        };
2403
2404        let payload = serde_json::to_string(&request)
2405            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch orders: {e}")))?;
2406
2407        let cmd = HandlerCommand::Send {
2408            payload,
2409            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_ORDER.to_vec()),
2410            request_id: Some(request_id),
2411            client_order_ids,
2412            op: Some(super::enums::OKXWsOperation::BatchOrders),
2413        };
2414
2415        self.send_cmd(cmd).await
2416    }
2417
2418    /// Cancel multiple orders in a single batch via WebSocket.
2419    ///
2420    /// # References
2421    ///
2422    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-batch-cancel-orders>
2423    async fn ws_batch_cancel_orders(
2424        &self,
2425        args: Vec<Value>,
2426        client_order_ids: Vec<ClientOrderId>,
2427    ) -> Result<(), OKXWsError> {
2428        let request_id = self.generate_unique_request_id();
2429        let request = OKXWsRequest::<Value> {
2430            id: Some(request_id.clone()),
2431            op: super::enums::OKXWsOperation::BatchCancelOrders,
2432            exp_time: None,
2433            args,
2434        };
2435
2436        let payload = serde_json::to_string(&request)
2437            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch cancel: {e}")))?;
2438
2439        let cmd = HandlerCommand::Send {
2440            payload,
2441            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_CANCEL.to_vec()),
2442            request_id: Some(request_id),
2443            client_order_ids,
2444            op: Some(super::enums::OKXWsOperation::BatchCancelOrders),
2445        };
2446
2447        self.send_cmd(cmd).await
2448    }
2449
2450    /// Amend multiple orders in a single batch via WebSocket.
2451    ///
2452    /// # References
2453    ///
2454    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-batch-amend-orders>
2455    async fn ws_batch_amend_orders(
2456        &self,
2457        args: Vec<Value>,
2458        client_order_ids: Vec<ClientOrderId>,
2459    ) -> Result<(), OKXWsError> {
2460        let request_id = self.generate_unique_request_id();
2461        let request = OKXWsRequest::<Value> {
2462            id: Some(request_id.clone()),
2463            op: super::enums::OKXWsOperation::BatchAmendOrders,
2464            exp_time: None,
2465            args,
2466        };
2467
2468        let payload = serde_json::to_string(&request)
2469            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize batch amend: {e}")))?;
2470
2471        let cmd = HandlerCommand::Send {
2472            payload,
2473            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_BATCH_AMEND.to_vec()),
2474            request_id: Some(request_id),
2475            client_order_ids,
2476            op: Some(super::enums::OKXWsOperation::BatchAmendOrders),
2477        };
2478
2479        self.send_cmd(cmd).await
2480    }
2481
2482    /// Submits an order, automatically routing conditional orders to the algo endpoint.
2483    ///
2484    /// # Errors
2485    ///
2486    /// Returns an error if the order parameters are invalid or if the request
2487    /// cannot be sent to the websocket client.
2488    ///
2489    /// # References
2490    ///
2491    /// - Regular orders: <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-place-order>
2492    /// - Algo orders: <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-place-algo-order>
2493    #[expect(clippy::too_many_arguments)]
2494    pub async fn submit_order(
2495        &self,
2496        trader_id: TraderId,
2497        strategy_id: StrategyId,
2498        instrument_id: InstrumentId,
2499        td_mode: OKXTradeMode,
2500        client_order_id: ClientOrderId,
2501        order_side: OrderSide,
2502        order_type: OrderType,
2503        quantity: Quantity,
2504        time_in_force: Option<TimeInForce>,
2505        price: Option<Price>,
2506        trigger_price: Option<Price>,
2507        post_only: Option<bool>,
2508        reduce_only: Option<bool>,
2509        quote_quantity: Option<bool>,
2510        position_side: Option<PositionSide>,
2511        attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
2512        px_usd: Option<String>,
2513        px_vol: Option<String>,
2514        speed_bump: Option<String>,
2515        outcome: Option<String>,
2516        slippage_pct: Option<String>,
2517        rpi: Option<bool>,
2518        rpi_taker_access: Option<bool>,
2519        rpi_px_round: Option<bool>,
2520    ) -> Result<(), OKXWsError> {
2521        let rpi = rpi.unwrap_or(false);
2522
2523        if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
2524            return Err(OKXWsError::ClientError(format!(
2525                "Unsupported order type: {order_type:?}",
2526            )));
2527        }
2528
2529        if let Some(tif) = time_in_force
2530            && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
2531        {
2532            return Err(OKXWsError::ClientError(format!(
2533                "Unsupported time in force: {tif:?}",
2534            )));
2535        }
2536
2537        let mut builder = WsPostOrderParamsBuilder::default();
2538
2539        let inst_id_code = self
2540            .get_inst_id_code(&instrument_id.symbol.inner())
2541            .ok_or_else(|| {
2542                OKXWsError::ClientError(format!(
2543                    "No instIdCode cached for {instrument_id}, cannot submit order"
2544                ))
2545            })?;
2546        builder.inst_id_code(inst_id_code);
2547
2548        builder.td_mode(td_mode);
2549        builder.cl_ord_id(client_order_id.as_str());
2550
2551        let (instrument_type, quote_currency) = {
2552            let instruments = self.instruments_cache.load();
2553            let symbol = instrument_id.symbol.inner();
2554            let instrument = instruments.get(&symbol).ok_or_else(|| {
2555                OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2556            })?;
2557            let instrument_type = okx_instrument_type(instrument)
2558                .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
2559            (instrument_type, instrument.quote_currency())
2560        };
2561
2562        // OKX options only support limit-style orders
2563        if instrument_type == OKXInstrumentType::Option
2564            && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
2565        {
2566            return Err(OKXWsError::ClientError(
2567                "Market orders are not supported for OKX options, use Limit orders instead"
2568                    .to_string(),
2569            ));
2570        }
2571
2572        match instrument_type {
2573            OKXInstrumentType::Spot | OKXInstrumentType::Margin => {
2574                // SPOT: ccy parameter is required by OKX for spot trading
2575                builder.ccy(quote_currency.to_string());
2576            }
2577            OKXInstrumentType::Swap | OKXInstrumentType::Futures => {
2578                // SWAP/FUTURES: use quote currency for margin (required by OKX)
2579                builder.ccy(quote_currency.to_string());
2580
2581                // For derivatives, posSide is required by OKX
2582                // Use Net for one-way mode (default for NETTING OMS)
2583                if position_side.is_none() {
2584                    builder.pos_side(OKXPositionSide::Net);
2585                }
2586            }
2587            OKXInstrumentType::Option => {
2588                builder.ccy(quote_currency.to_string());
2589
2590                if position_side.is_none() {
2591                    builder.pos_side(OKXPositionSide::Net);
2592                }
2593                // reduceOnly is not applicable to options per OKX docs
2594            }
2595            OKXInstrumentType::Events => {}
2596            _ => {
2597                builder.ccy(quote_currency.to_string());
2598
2599                if position_side.is_none() {
2600                    builder.pos_side(OKXPositionSide::Net);
2601                }
2602            }
2603        }
2604
2605        if should_send_reduce_only(instrument_type, td_mode, position_side, reduce_only) {
2606            builder.reduce_only(true);
2607        }
2608
2609        if let Some(attach_algo_ords) = attach_algo_ords {
2610            builder.attach_algo_ords(attach_algo_ords);
2611        }
2612
2613        // For SPOT market orders in Cash mode, handle tgtCcy parameter
2614        // https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-place-order
2615        // OKX API default behavior for SPOT market orders:
2616        // - BUY orders default to tgtCcy=quote_ccy (sz represents quote currency amount)
2617        // - SELL orders default to tgtCcy=base_ccy (sz represents base currency amount)
2618        // Note: tgtCcy is ONLY supported for Cash trading mode, not for margin modes (Cross/Isolated)
2619        if instrument_type == OKXInstrumentType::Spot
2620            && order_type == OrderType::Market
2621            && td_mode == OKXTradeMode::Cash
2622        {
2623            match quote_quantity {
2624                Some(true) => {
2625                    builder.tgt_ccy(OKXTargetCurrency::QuoteCcy);
2626                }
2627                // For BUY orders, must explicitly set to base_ccy to override OKX default
2628                Some(false) if order_side == OrderSide::Buy => {
2629                    builder.tgt_ccy(OKXTargetCurrency::BaseCcy);
2630                }
2631                // For SELL orders with quote_quantity=false, omit tgtCcy (OKX defaults to base_ccy correctly)
2632                Some(false) | None => {}
2633            }
2634        }
2635
2636        builder.side(order_side);
2637
2638        if let Some(pos_side) = position_side {
2639            builder.pos_side(pos_side);
2640        }
2641
2642        // OKX implements FOK/IOC as order types rather than separate time-in-force
2643        // Market + FOK is unsupported (FOK requires a limit price)
2644        // optimal_limit_ioc is only supported for SWAP/FUTURES, not SPOT or OPTION
2645        if rpi && order_type != OrderType::Limit {
2646            return Err(OKXWsError::ClientError(
2647                "OKX RPI orders require a limit order".to_string(),
2648            ));
2649        }
2650
2651        let (okx_ord_type, price) = if rpi {
2652            (OKXOrderType::Rpi, price)
2653        } else if post_only.unwrap_or(false) {
2654            (OKXOrderType::PostOnly, price)
2655        } else if let Some(tif) = time_in_force {
2656            match (order_type, tif) {
2657                (OrderType::Market, TimeInForce::Fok) => {
2658                    return Err(OKXWsError::ClientError(
2659                        "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string()
2660                    ));
2661                }
2662                (OrderType::Market, TimeInForce::Ioc) => {
2663                    // optimal_limit_ioc only works for SWAP/FUTURES
2664                    if matches!(
2665                        instrument_type,
2666                        OKXInstrumentType::Spot | OKXInstrumentType::Option
2667                    ) {
2668                        (OKXOrderType::Market, price)
2669                    } else {
2670                        (OKXOrderType::OptimalLimitIoc, price)
2671                    }
2672                }
2673                (OrderType::Limit, TimeInForce::Fok) => {
2674                    // OKX uses op_fok for options FOK orders
2675                    if instrument_type == OKXInstrumentType::Option {
2676                        (OKXOrderType::OpFok, price)
2677                    } else {
2678                        (OKXOrderType::Fok, price)
2679                    }
2680                }
2681                (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
2682                _ => (OKXOrderType::from(order_type), price),
2683            }
2684        } else {
2685            (OKXOrderType::from(order_type), price)
2686        };
2687
2688        log::debug!(
2689            "Order type mapping: order_type={order_type:?}, time_in_force={time_in_force:?}, post_only={post_only:?} -> okx_ord_type={okx_ord_type:?}"
2690        );
2691
2692        let speed_bump = if instrument_type == OKXInstrumentType::Events {
2693            if outcome.is_none() {
2694                return Err(OKXWsError::ClientError(
2695                    "OKX event contract orders require `outcome`".to_string(),
2696                ));
2697            }
2698
2699            if okx_ord_type == OKXOrderType::PostOnly {
2700                speed_bump
2701            } else {
2702                Some(speed_bump.unwrap_or_else(|| "1".to_string()))
2703            }
2704        } else {
2705            speed_bump
2706        };
2707
2708        if let Some(speed_bump) = speed_bump {
2709            builder.speed_bump(speed_bump);
2710        }
2711
2712        if let Some(outcome) = outcome {
2713            builder.outcome(outcome);
2714        }
2715
2716        if let Some(slippage) = slippage_pct {
2717            builder.slippage_pct(slippage);
2718        }
2719
2720        if let Some(rpi_taker_access) = rpi_taker_access {
2721            builder.rpi_taker_access(rpi_taker_access);
2722        }
2723
2724        if let Some(rpi_px_round) = rpi_px_round {
2725            builder.rpi_px_round(rpi_px_round);
2726        }
2727
2728        builder.ord_type(okx_ord_type);
2729        builder.sz(quantity.to_string());
2730
2731        // For options: pxUsd/pxVol are mutually exclusive with px
2732        if let Some(usd) = px_usd {
2733            builder.px_usd(usd);
2734        } else if let Some(vol) = px_vol {
2735            builder.px_vol(vol);
2736        } else if let Some(tp) = trigger_price {
2737            builder.px(tp.to_string());
2738        } else if let Some(p) = price {
2739            builder.px(p.to_string());
2740        }
2741
2742        builder.tag(OKX_NAUTILUS_BROKER_ID);
2743
2744        let params = builder
2745            .build()
2746            .map_err(|e| OKXWsError::ClientError(format!("Build order params error: {e}")))?;
2747
2748        let request_id = self.generate_unique_request_id();
2749        let request = OKXWsRequest {
2750            id: Some(request_id.clone()),
2751            op: super::enums::OKXWsOperation::Order,
2752            exp_time: None,
2753            args: vec![params],
2754        };
2755
2756        let payload = serde_json::to_string(&request)
2757            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize order: {e}")))?;
2758
2759        let cl_ord_key = client_order_id.to_string();
2760        self.pending_orders.insert(
2761            cl_ord_key.clone(),
2762            PendingOrderInfo {
2763                trader_id,
2764                strategy_id,
2765                instrument_id,
2766            },
2767        );
2768
2769        let cmd = HandlerCommand::Send {
2770            payload,
2771            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ORDER.to_vec()),
2772            request_id: Some(request_id),
2773            client_order_ids: vec![client_order_id],
2774            op: Some(super::enums::OKXWsOperation::Order),
2775        };
2776
2777        let result = self.send_cmd(cmd).await;
2778
2779        if result.is_err() {
2780            self.pending_orders.remove(&cl_ord_key);
2781        }
2782
2783        result
2784    }
2785
2786    /// Place a new order via WebSocket.
2787    ///
2788    /// # References
2789    ///
2790    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-place-order>
2791    /// Modifies an existing order.
2792    ///
2793    /// # Errors
2794    ///
2795    /// Returns an error if the amend parameters are invalid or if the
2796    /// websocket request fails to send.
2797    ///
2798    /// # References
2799    ///
2800    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-amend-order>.
2801    #[expect(clippy::too_many_arguments)]
2802    pub async fn modify_order(
2803        &self,
2804        trader_id: TraderId,
2805        strategy_id: StrategyId,
2806        instrument_id: InstrumentId,
2807        client_order_id: Option<ClientOrderId>,
2808        price: Option<Price>,
2809        quantity: Option<Quantity>,
2810        venue_order_id: Option<VenueOrderId>,
2811        new_px_usd: Option<String>,
2812        new_px_vol: Option<String>,
2813        speed_bump: Option<String>,
2814        rpi_taker_access: Option<bool>,
2815        rpi_px_round: Option<bool>,
2816    ) -> Result<(), OKXWsError> {
2817        let mut builder = WsAmendOrderParamsBuilder::default();
2818
2819        let inst_id_code = self
2820            .get_inst_id_code(&instrument_id.symbol.inner())
2821            .ok_or_else(|| {
2822                OKXWsError::ClientError(format!(
2823                    "No instIdCode cached for {instrument_id}, cannot amend order"
2824                ))
2825            })?;
2826        builder.inst_id_code(inst_id_code);
2827
2828        if let Some(venue_order_id) = venue_order_id {
2829            builder.ord_id(venue_order_id.as_str());
2830        }
2831
2832        let cl_ord_key = client_order_id.map(|id| id.to_string());
2833
2834        if let Some(client_order_id) = client_order_id {
2835            builder.cl_ord_id(client_order_id.as_str());
2836            self.pending_amends.insert(
2837                client_order_id.to_string(),
2838                PendingOrderInfo {
2839                    trader_id,
2840                    strategy_id,
2841                    instrument_id,
2842                },
2843            );
2844        }
2845
2846        // For options: newPxUsd/newPxVol are mutually exclusive with newPx
2847        if let Some(usd) = new_px_usd {
2848            builder.new_px_usd(usd);
2849        } else if let Some(vol) = new_px_vol {
2850            builder.new_px_vol(vol);
2851        } else if let Some(price) = price {
2852            builder.new_px(price.to_string());
2853        }
2854
2855        if let Some(quantity) = quantity {
2856            builder.new_sz(quantity.to_string());
2857        }
2858
2859        if let Some(speed_bump) = speed_bump {
2860            builder.speed_bump(speed_bump);
2861        }
2862
2863        if let Some(rpi_taker_access) = rpi_taker_access {
2864            builder.rpi_taker_access(rpi_taker_access);
2865        }
2866
2867        if let Some(rpi_px_round) = rpi_px_round {
2868            builder.rpi_px_round(rpi_px_round);
2869        }
2870
2871        let params = builder
2872            .build()
2873            .map_err(|e| OKXWsError::ClientError(format!("Build amend params error: {e}")))?;
2874
2875        let request_id = self.generate_unique_request_id();
2876        let request = OKXWsRequest {
2877            id: Some(request_id.clone()),
2878            op: super::enums::OKXWsOperation::AmendOrder,
2879            exp_time: None,
2880            args: vec![params],
2881        };
2882
2883        let payload = serde_json::to_string(&request)
2884            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize amend: {e}")))?;
2885
2886        let cmd = HandlerCommand::Send {
2887            payload,
2888            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_AMEND.to_vec()),
2889            request_id: Some(request_id),
2890            client_order_ids: client_order_id.into_iter().collect(),
2891            op: Some(super::enums::OKXWsOperation::AmendOrder),
2892        };
2893
2894        let result = self.send_cmd(cmd).await;
2895
2896        if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2897            self.pending_amends.remove(key);
2898        }
2899
2900        result
2901    }
2902
2903    /// Cancels an existing order.
2904    ///
2905    /// # Errors
2906    ///
2907    /// Returns an error if the cancel parameters are invalid or if the
2908    /// cancellation request fails to send.
2909    ///
2910    /// # References
2911    ///
2912    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-cancel-order>.
2913    pub async fn cancel_order(
2914        &self,
2915        trader_id: TraderId,
2916        strategy_id: StrategyId,
2917        instrument_id: InstrumentId,
2918        client_order_id: Option<ClientOrderId>,
2919        venue_order_id: Option<VenueOrderId>,
2920    ) -> Result<(), OKXWsError> {
2921        let mut builder = WsCancelOrderParamsBuilder::default();
2922
2923        let inst_id_code = self
2924            .get_inst_id_code(&instrument_id.symbol.inner())
2925            .ok_or_else(|| {
2926                OKXWsError::ClientError(format!(
2927                    "No instIdCode cached for {instrument_id}, cannot cancel order"
2928                ))
2929            })?;
2930        builder.inst_id_code(inst_id_code);
2931
2932        if let Some(venue_order_id) = venue_order_id {
2933            builder.ord_id(venue_order_id.as_str());
2934        }
2935
2936        let cl_ord_key = client_order_id.map(|id| id.to_string());
2937
2938        if let Some(client_order_id) = client_order_id {
2939            builder.cl_ord_id(client_order_id.as_str());
2940            self.pending_cancels.insert(
2941                client_order_id.to_string(),
2942                PendingOrderInfo {
2943                    trader_id,
2944                    strategy_id,
2945                    instrument_id,
2946                },
2947            );
2948        }
2949
2950        let params = builder
2951            .build()
2952            .map_err(|e| OKXWsError::ClientError(format!("Build cancel params error: {e}")))?;
2953
2954        let request_id = self.generate_unique_request_id();
2955        let request = OKXWsRequest {
2956            id: Some(request_id.clone()),
2957            op: super::enums::OKXWsOperation::CancelOrder,
2958            exp_time: None,
2959            args: vec![params],
2960        };
2961
2962        let payload = serde_json::to_string(&request)
2963            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel: {e}")))?;
2964
2965        let cmd = HandlerCommand::Send {
2966            payload,
2967            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_CANCEL.to_vec()),
2968            request_id: Some(request_id),
2969            client_order_ids: client_order_id.into_iter().collect(),
2970            op: Some(super::enums::OKXWsOperation::CancelOrder),
2971        };
2972
2973        let result = self.send_cmd(cmd).await;
2974
2975        if let (Err(_), Some(key)) = (&result, &cl_ord_key) {
2976            self.pending_cancels.remove(key);
2977        }
2978
2979        result
2980    }
2981
2982    /// Mass cancels all orders for a given instrument via WebSocket.
2983    ///
2984    /// # Errors
2985    ///
2986    /// Returns an error if instrument metadata cannot be resolved or if the
2987    /// cancel request fails to send.
2988    ///
2989    /// # References
2990    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-mass-cancel-order>
2991    pub async fn mass_cancel_orders(&self, instrument_id: InstrumentId) -> Result<(), OKXWsError> {
2992        let (inst_type, inst_family) = {
2993            let instrument = self
2994                .instruments_cache
2995                .get_cloned(&instrument_id.symbol.inner())
2996                .ok_or_else(|| {
2997                    OKXWsError::ClientError(format!("Unknown instrument {instrument_id}"))
2998                })?;
2999
3000            let inst_type = okx_instrument_type(&instrument)
3001                .map_err(|e| OKXWsError::ClientError(e.to_string()))?;
3002
3003            let symbol = instrument.symbol().inner();
3004            let inst_family = match &instrument {
3005                InstrumentAny::CurrencyPair(_) => symbol.as_str().to_string(),
3006                InstrumentAny::CryptoPerpetual(_) => symbol
3007                    .as_str()
3008                    .strip_suffix("-SWAP")
3009                    .unwrap_or(symbol.as_str())
3010                    .to_string(),
3011                InstrumentAny::CryptoFuture(_) => {
3012                    let s = symbol.as_str();
3013                    if let Some(idx) = s.rfind('-') {
3014                        s[..idx].to_string()
3015                    } else {
3016                        s.to_string()
3017                    }
3018                }
3019                _ => {
3020                    return Err(OKXWsError::ClientError(
3021                        "Unsupported instrument type for mass cancel".to_string(),
3022                    ));
3023                }
3024            };
3025
3026            (inst_type, inst_family)
3027        };
3028
3029        let params = WsMassCancelParams {
3030            inst_type,
3031            inst_family: Ustr::from(&inst_family),
3032        };
3033
3034        let request_id = self.generate_unique_request_id();
3035        let request = OKXWsRequest {
3036            id: Some(request_id.clone()),
3037            op: super::enums::OKXWsOperation::MassCancel,
3038            exp_time: None,
3039            args: vec![
3040                serde_json::to_value(params).map_err(|e| OKXWsError::JsonError(e.to_string()))?,
3041            ],
3042        };
3043
3044        let payload = serde_json::to_string(&request)
3045            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize mass cancel: {e}")))?;
3046
3047        let cmd = HandlerCommand::Send {
3048            payload,
3049            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_MASS_CANCEL.to_vec()),
3050            request_id: Some(request_id),
3051            client_order_ids: Vec::new(),
3052            op: Some(super::enums::OKXWsOperation::MassCancel),
3053        };
3054
3055        self.send_cmd(cmd).await
3056    }
3057
3058    /// Submits multiple orders.
3059    ///
3060    /// # Errors
3061    ///
3062    /// Returns an error if any batch order parameters are invalid or if the
3063    /// batch request fails to send.
3064    #[expect(clippy::type_complexity)]
3065    pub async fn batch_submit_orders(
3066        &self,
3067        orders: Vec<(
3068            OKXInstrumentType,
3069            InstrumentId,
3070            OKXTradeMode,
3071            ClientOrderId,
3072            OrderSide,
3073            Option<PositionSide>,
3074            OrderType,
3075            Quantity,
3076            Option<Price>,
3077            Option<Price>,
3078            Option<bool>,
3079            Option<bool>,
3080            Option<String>,
3081            Option<String>,
3082            Option<bool>,
3083            Option<bool>,
3084            Option<bool>,
3085        )>,
3086    ) -> Result<(), OKXWsError> {
3087        let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.3).collect();
3088        let args: Vec<Value> = {
3089            let mut args = Vec::with_capacity(orders.len());
3090            let inst_id_codes = self.inst_id_code_cache.load();
3091            let instruments = self.instruments_cache.load();
3092
3093            for (
3094                inst_type,
3095                inst_id,
3096                td_mode,
3097                cl_ord_id,
3098                ord_side,
3099                pos_side,
3100                ord_type,
3101                qty,
3102                pr,
3103                tp,
3104                post_only,
3105                reduce_only,
3106                speed_bump,
3107                outcome,
3108                rpi,
3109                rpi_taker_access,
3110                rpi_px_round,
3111            ) in orders
3112            {
3113                let rpi = rpi.unwrap_or(false);
3114                let mut builder = WsPostOrderParamsBuilder::default();
3115
3116                let (inst_id_symbol, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3117                    &inst_id_codes,
3118                    &inst_id,
3119                    "submit",
3120                )?;
3121                builder.inst_id_code(inst_id_code);
3122
3123                builder.td_mode(td_mode);
3124                builder.cl_ord_id(cl_ord_id.as_str());
3125                builder.side(ord_side);
3126
3127                if inst_type != OKXInstrumentType::Events
3128                    && let Some(instrument) = instruments.get(&inst_id_symbol)
3129                {
3130                    builder.ccy(instrument.quote_currency().to_string());
3131                }
3132
3133                if let Some(ps) = pos_side {
3134                    builder.pos_side(OKXPositionSide::from(ps));
3135                } else if matches!(
3136                    inst_type,
3137                    OKXInstrumentType::Swap
3138                        | OKXInstrumentType::Futures
3139                        | OKXInstrumentType::Option
3140                ) {
3141                    builder.pos_side(OKXPositionSide::Net);
3142                }
3143
3144                if rpi && ord_type != OrderType::Limit {
3145                    return Err(OKXWsError::ClientError(
3146                        "OKX RPI batch orders require limit orders".to_string(),
3147                    ));
3148                }
3149
3150                let okx_ord_type = if rpi {
3151                    OKXOrderType::Rpi
3152                } else if post_only.unwrap_or(false) {
3153                    OKXOrderType::PostOnly
3154                } else {
3155                    match ord_type {
3156                        OrderType::Market => OKXOrderType::Market,
3157                        OrderType::Limit => OKXOrderType::Limit,
3158                        OrderType::MarketToLimit => OKXOrderType::Ioc,
3159                        _ => {
3160                            return Err(OKXWsError::ClientError(format!(
3161                                "Unsupported order type for batch submit: {ord_type:?}"
3162                            )));
3163                        }
3164                    }
3165                };
3166
3167                builder.ord_type(okx_ord_type);
3168                builder.sz(qty.to_string());
3169
3170                if let Some(p) = pr {
3171                    builder.px(p.to_string());
3172                } else if let Some(p) = tp {
3173                    builder.px(p.to_string());
3174                }
3175
3176                if should_send_reduce_only(inst_type, td_mode, pos_side, reduce_only) {
3177                    builder.reduce_only(true);
3178                }
3179
3180                let speed_bump = if inst_type == OKXInstrumentType::Events {
3181                    if outcome.is_none() {
3182                        return Err(OKXWsError::ClientError(
3183                            "OKX event contract orders require `outcome`".to_string(),
3184                        ));
3185                    }
3186
3187                    if okx_ord_type == OKXOrderType::PostOnly {
3188                        speed_bump
3189                    } else {
3190                        Some(speed_bump.unwrap_or_else(|| "1".to_string()))
3191                    }
3192                } else {
3193                    speed_bump
3194                };
3195
3196                if let Some(speed_bump) = speed_bump {
3197                    builder.speed_bump(speed_bump);
3198                }
3199
3200                if let Some(outcome) = outcome {
3201                    builder.outcome(outcome);
3202                }
3203
3204                if let Some(rpi_taker_access) = rpi_taker_access {
3205                    builder.rpi_taker_access(rpi_taker_access);
3206                }
3207
3208                if let Some(rpi_px_round) = rpi_px_round {
3209                    builder.rpi_px_round(rpi_px_round);
3210                }
3211
3212                builder.tag(OKX_NAUTILUS_BROKER_ID);
3213
3214                let params = builder.build().map_err(|e| {
3215                    OKXWsError::ClientError(format!("Build order params error: {e}"))
3216                })?;
3217                let val = serde_json::to_value(params)
3218                    .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3219                args.push(val);
3220            }
3221            args
3222        };
3223
3224        self.ws_batch_place_orders(args, client_order_ids).await
3225    }
3226
3227    /// Modifies multiple orders.
3228    ///
3229    /// # Errors
3230    ///
3231    /// Returns an error if amend parameters are invalid or if the batch request
3232    /// fails to send.
3233    #[expect(clippy::type_complexity)]
3234    pub async fn batch_modify_orders(
3235        &self,
3236        orders: Vec<(
3237            OKXInstrumentType,
3238            InstrumentId,
3239            ClientOrderId,
3240            Option<String>,
3241            Option<Price>,
3242            Option<Quantity>,
3243            Option<String>,
3244            Option<bool>,
3245            Option<bool>,
3246        )>,
3247    ) -> Result<(), OKXWsError> {
3248        let client_order_ids: Vec<ClientOrderId> = orders.iter().map(|o| o.2).collect();
3249        let args: Vec<Value> = {
3250            let mut args = Vec::with_capacity(orders.len());
3251            let inst_id_codes = self.inst_id_code_cache.load();
3252
3253            for (
3254                _inst_type,
3255                inst_id,
3256                cl_ord_id,
3257                request_id,
3258                pr,
3259                sz,
3260                speed_bump,
3261                rpi_taker_access,
3262                rpi_px_round,
3263            ) in orders
3264            {
3265                let mut builder = WsAmendOrderParamsBuilder::default();
3266
3267                let (_, inst_id_code) =
3268                    Self::inst_id_symbol_and_code_from_snapshot(&inst_id_codes, &inst_id, "amend")?;
3269                builder.inst_id_code(inst_id_code);
3270
3271                builder.cl_ord_id(cl_ord_id.as_str());
3272
3273                if let Some(request_id) = request_id {
3274                    builder.req_id(request_id);
3275                }
3276
3277                if let Some(p) = pr {
3278                    builder.new_px(p.to_string());
3279                }
3280
3281                if let Some(q) = sz {
3282                    builder.new_sz(q.to_string());
3283                }
3284
3285                if let Some(speed_bump) = speed_bump {
3286                    builder.speed_bump(speed_bump);
3287                }
3288
3289                if let Some(rpi_taker_access) = rpi_taker_access {
3290                    builder.rpi_taker_access(rpi_taker_access);
3291                }
3292
3293                if let Some(rpi_px_round) = rpi_px_round {
3294                    builder.rpi_px_round(rpi_px_round);
3295                }
3296
3297                let params = builder.build().map_err(|e| {
3298                    OKXWsError::ClientError(format!("Build amend batch params error: {e}"))
3299                })?;
3300                let val = serde_json::to_value(params)
3301                    .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3302                args.push(val);
3303            }
3304            args
3305        };
3306
3307        self.ws_batch_amend_orders(args, client_order_ids).await
3308    }
3309
3310    /// Cancels multiple orders.
3311    ///
3312    /// Supports up to 20 orders per batch.
3313    ///
3314    /// # Errors
3315    ///
3316    /// Returns an error if cancel parameters are invalid or if the batch
3317    /// request fails to send.
3318    ///
3319    /// # References
3320    ///
3321    /// <https://www.okx.com/docs-v5/en/#order-book-trading-websocket-batch-cancel-orders>
3322    pub async fn batch_cancel_orders(
3323        &self,
3324        orders: Vec<(InstrumentId, Option<ClientOrderId>, Option<VenueOrderId>)>,
3325    ) -> Result<(), OKXWsError> {
3326        let client_order_ids: Vec<ClientOrderId> = orders
3327            .iter()
3328            .filter_map(|(_, cl_ord_id, _)| *cl_ord_id)
3329            .collect();
3330        let args: Vec<Value> = {
3331            let mut args = Vec::with_capacity(orders.len());
3332            let inst_id_codes = self.inst_id_code_cache.load();
3333
3334            for (inst_id, cl_ord_id, ord_id) in orders {
3335                let mut builder = WsCancelOrderParamsBuilder::default();
3336
3337                let (_, inst_id_code) = Self::inst_id_symbol_and_code_from_snapshot(
3338                    &inst_id_codes,
3339                    &inst_id,
3340                    "cancel",
3341                )?;
3342                builder.inst_id_code(inst_id_code);
3343
3344                if let Some(c) = cl_ord_id {
3345                    builder.cl_ord_id(c.as_str());
3346                }
3347
3348                if let Some(o) = ord_id {
3349                    builder.ord_id(o.as_str());
3350                }
3351
3352                let params = builder.build().map_err(|e| {
3353                    OKXWsError::ClientError(format!("Build cancel batch params error: {e}"))
3354                })?;
3355                let val = serde_json::to_value(params)
3356                    .map_err(|e| OKXWsError::JsonError(e.to_string()))?;
3357                args.push(val);
3358            }
3359            args
3360        };
3361
3362        self.ws_batch_cancel_orders(args, client_order_ids).await
3363    }
3364
3365    /// Submits an algo order (conditional/stop order).
3366    ///
3367    /// # Errors
3368    ///
3369    /// Returns an error if the order parameters are invalid or if the request
3370    /// cannot be sent.
3371    ///
3372    /// # References
3373    ///
3374    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-place-algo-order>
3375    #[expect(clippy::too_many_arguments)]
3376    pub async fn submit_algo_order(
3377        &self,
3378        _trader_id: TraderId,
3379        _strategy_id: StrategyId,
3380        instrument_id: InstrumentId,
3381        td_mode: OKXTradeMode,
3382        client_order_id: ClientOrderId,
3383        order_side: OrderSide,
3384        order_type: OrderType,
3385        quantity: Quantity,
3386        trigger_price: Option<Price>,
3387        trigger_type: Option<TriggerType>,
3388        limit_price: Option<Price>,
3389        reduce_only: Option<bool>,
3390        callback_ratio: Option<String>,
3391        callback_spread: Option<String>,
3392        activation_price: Option<Price>,
3393    ) -> Result<(), OKXWsError> {
3394        if !is_conditional_order(order_type) {
3395            return Err(OKXWsError::ClientError(format!(
3396                "Order type {order_type:?} is not a conditional order"
3397            )));
3398        }
3399
3400        let mut builder = WsPostAlgoOrderParamsBuilder::default();
3401
3402        if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
3403            return Err(OKXWsError::ClientError(
3404                "Invalid order side for OKX".to_string(),
3405            ));
3406        }
3407
3408        let inst_id_code = self
3409            .get_inst_id_code(&instrument_id.symbol.inner())
3410            .ok_or_else(|| {
3411                OKXWsError::ClientError(format!(
3412                    "No instIdCode cached for {instrument_id}, cannot submit algo order"
3413                ))
3414            })?;
3415        builder.inst_id_code(inst_id_code);
3416
3417        builder.td_mode(td_mode);
3418        builder.cl_ord_id(client_order_id.as_str());
3419        builder.side(order_side);
3420        builder.ord_type(
3421            conditional_order_to_algo_type(order_type)
3422                .map_err(|e| OKXWsError::ClientError(e.to_string()))?,
3423        );
3424        builder.sz(quantity.to_string());
3425
3426        if let Some(tp) = trigger_price {
3427            builder.trigger_px(tp.to_string());
3428        }
3429
3430        // Map Nautilus TriggerType to OKX trigger type
3431        let okx_trigger_type = trigger_type.map_or(OKXTriggerType::Last, Into::into);
3432        builder.trigger_px_type(okx_trigger_type);
3433
3434        // For stop-limit orders, set the limit price
3435        if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
3436            && let Some(price) = limit_price
3437        {
3438            builder.order_px(price.to_string());
3439        }
3440
3441        if let Some(reduce) = reduce_only {
3442            builder.reduce_only(reduce);
3443        }
3444
3445        if let Some(ratio) = callback_ratio {
3446            builder.callback_ratio(ratio);
3447        }
3448
3449        if let Some(spread) = callback_spread {
3450            builder.callback_spread(spread);
3451        }
3452
3453        if let Some(active) = activation_price {
3454            builder.active_px(active.to_string());
3455        }
3456
3457        builder.tag(OKX_NAUTILUS_BROKER_ID);
3458
3459        let params = builder
3460            .build()
3461            .map_err(|e| OKXWsError::ClientError(format!("Build algo order params error: {e}")))?;
3462
3463        let request_id = self.generate_unique_request_id();
3464        let request = OKXWsRequest {
3465            id: Some(request_id.clone()),
3466            op: super::enums::OKXWsOperation::OrderAlgo,
3467            exp_time: None,
3468            args: vec![params],
3469        };
3470
3471        let payload = serde_json::to_string(&request)
3472            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize algo order: {e}")))?;
3473
3474        let cmd = HandlerCommand::Send {
3475            payload,
3476            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_ORDER.to_vec()),
3477            request_id: Some(request_id),
3478            client_order_ids: vec![client_order_id],
3479            op: Some(super::enums::OKXWsOperation::OrderAlgo),
3480        };
3481
3482        self.send_cmd(cmd).await
3483    }
3484
3485    /// Cancels an algo order.
3486    ///
3487    /// # Errors
3488    ///
3489    /// Returns an error if cancel parameters are invalid or if the request
3490    /// fails to send.
3491    ///
3492    /// # References
3493    ///
3494    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-cancel-algo-order>
3495    pub async fn cancel_algo_order(
3496        &self,
3497        _trader_id: TraderId,
3498        _strategy_id: StrategyId,
3499        instrument_id: InstrumentId,
3500        client_order_id: Option<ClientOrderId>,
3501        algo_order_id: Option<String>,
3502    ) -> Result<(), OKXWsError> {
3503        let mut builder = super::messages::WsCancelAlgoOrderParamsBuilder::default();
3504
3505        let inst_id_code = self
3506            .get_inst_id_code(&instrument_id.symbol.inner())
3507            .ok_or_else(|| {
3508                OKXWsError::ClientError(format!(
3509                    "No instIdCode cached for {instrument_id}, cannot cancel algo order"
3510                ))
3511            })?;
3512        builder.inst_id_code(inst_id_code);
3513
3514        if let Some(algo_id) = algo_order_id {
3515            builder.algo_id(algo_id);
3516        }
3517
3518        if let Some(cl_ord_id) = client_order_id {
3519            builder.algo_cl_ord_id(cl_ord_id.to_string());
3520        }
3521
3522        let params = builder
3523            .build()
3524            .map_err(|e| OKXWsError::ClientError(format!("Build cancel algo params error: {e}")))?;
3525
3526        let request_id = self.generate_unique_request_id();
3527        let request = OKXWsRequest {
3528            id: Some(request_id.clone()),
3529            op: super::enums::OKXWsOperation::CancelAlgos,
3530            exp_time: None,
3531            args: vec![params],
3532        };
3533
3534        let payload = serde_json::to_string(&request)
3535            .map_err(|e| OKXWsError::JsonError(format!("Failed to serialize cancel algo: {e}")))?;
3536
3537        let cmd = HandlerCommand::Send {
3538            payload,
3539            rate_limit_keys: Some(OKX_RATE_LIMIT_KEY_ALGO_CANCEL.to_vec()),
3540            request_id: Some(request_id),
3541            client_order_ids: client_order_id.into_iter().collect(),
3542            op: Some(super::enums::OKXWsOperation::CancelAlgos),
3543        };
3544
3545        self.send_cmd(cmd).await
3546    }
3547
3548    /// Sends a command to the handler.
3549    async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), OKXWsError> {
3550        self.cmd_tx
3551            .read()
3552            .await
3553            .send(cmd)
3554            .map_err(|e| OKXWsError::HandlerUnavailable(e.to_string()))
3555    }
3556}
3557
3558fn should_send_reduce_only(
3559    instrument_type: OKXInstrumentType,
3560    td_mode: OKXTradeMode,
3561    position_side: Option<PositionSide>,
3562    reduce_only: Option<bool>,
3563) -> bool {
3564    if reduce_only != Some(true) {
3565        return false;
3566    }
3567
3568    match instrument_type {
3569        OKXInstrumentType::Spot | OKXInstrumentType::Margin => td_mode != OKXTradeMode::Cash,
3570        OKXInstrumentType::Swap | OKXInstrumentType::Futures => position_side.is_none(),
3571        OKXInstrumentType::Any => true,
3572        OKXInstrumentType::Option | OKXInstrumentType::Events => false,
3573    }
3574}
3575
3576fn ws_channel_for_book(channel: OKXBookChannel) -> OKXWsChannel {
3577    match channel {
3578        OKXBookChannel::Book => OKXWsChannel::Books,
3579        OKXBookChannel::BookL2Tbt => OKXWsChannel::BooksTbt,
3580        OKXBookChannel::Books50L2Tbt => OKXWsChannel::Books50Tbt,
3581        OKXBookChannel::BooksRpi => OKXWsChannel::BooksRpi,
3582        OKXBookChannel::SprdBooks5 => OKXWsChannel::SprdBooks5,
3583    }
3584}
3585
3586fn log_receiver_dropped(signal: &AtomicBool, item: &str) {
3587    if signal.load(Ordering::Acquire) {
3588        log::debug!("Receiver dropped after stop signal while forwarding {item}");
3589    } else {
3590        log::error!("Failed to send {item} through channel: receiver dropped");
3591    }
3592}
3593
3594#[cfg(test)]
3595mod tests {
3596    use nautilus_core::time::get_atomic_clock_realtime;
3597    use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
3598    use nautilus_model::{identifiers::ClientId, instruments::stubs::crypto_perpetual_ethusdt};
3599    use nautilus_network::RECONNECTED;
3600    use rstest::rstest;
3601    use tokio_tungstenite::tungstenite::Message;
3602
3603    use super::*;
3604    use crate::{
3605        common::{
3606            consts::{OKX_POST_ONLY_CANCEL_SOURCE, OKX_VENUE},
3607            enums::{
3608                OKXExecType, OKXOrderCategory, OKXOrderStatus, OKXPriceType, OKXQuickMarginType,
3609                OKXSelfTradePreventionMode, OKXSide,
3610            },
3611        },
3612        websocket::{
3613            handler::is_post_only_auto_cancel,
3614            messages::{OKXOrderMsg, OKXWebSocketError, OKXWsFrame},
3615        },
3616    };
3617
3618    struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
3619
3620    impl Drop for DropSignal {
3621        fn drop(&mut self) {
3622            if let Some(sender) = self.0.take() {
3623                let _ = sender.send(());
3624            }
3625        }
3626    }
3627
3628    struct BlockingDrop(Arc<(parking_lot::Mutex<bool>, parking_lot::Condvar)>);
3629
3630    impl Drop for BlockingDrop {
3631        fn drop(&mut self) {
3632            let (lock, condvar) = &*self.0;
3633            let mut released = lock.lock();
3634            condvar.wait_while(&mut released, |released| !*released);
3635        }
3636    }
3637
3638    #[rstest]
3639    #[case(OKXBookChannel::Book, OKXWsChannel::Books)]
3640    #[case(OKXBookChannel::BookL2Tbt, OKXWsChannel::BooksTbt)]
3641    #[case(OKXBookChannel::Books50L2Tbt, OKXWsChannel::Books50Tbt)]
3642    #[case(OKXBookChannel::BooksRpi, OKXWsChannel::BooksRpi)]
3643    #[case(OKXBookChannel::SprdBooks5, OKXWsChannel::SprdBooks5)]
3644    fn test_ws_channel_for_book(#[case] channel: OKXBookChannel, #[case] expected: OKXWsChannel) {
3645        assert_eq!(ws_channel_for_book(channel), expected);
3646    }
3647
3648    #[rstest]
3649    fn test_timestamp_format_for_websocket_auth() {
3650        let timestamp = SystemTime::now()
3651            .duration_since(SystemTime::UNIX_EPOCH)
3652            .expect("System time should be after UNIX epoch")
3653            .as_secs()
3654            .to_string();
3655
3656        timestamp.parse::<u64>().unwrap();
3657        assert_eq!(timestamp.len(), 10);
3658        assert!(timestamp.chars().all(|c| c.is_ascii_digit()));
3659    }
3660
3661    #[rstest]
3662    fn test_new_without_credentials() {
3663        let client = OKXWebSocketClient::default();
3664        assert!(client.credential.is_none());
3665        assert_eq!(client.api_key(), None);
3666    }
3667
3668    #[rstest]
3669    fn test_instruments_cache_arc_observes_post_clone_writes() {
3670        let client = OKXWebSocketClient::default();
3671        let cache = client.instruments_cache_arc();
3672        assert!(cache.load().is_empty());
3673
3674        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3675        let symbol = instrument.symbol().inner();
3676        client.cache_instruments(std::slice::from_ref(&instrument));
3677
3678        let loaded = cache.load();
3679        assert_eq!(loaded.len(), 1);
3680        let stored = loaded.get(&symbol).expect("instrument not refreshed");
3681        assert_eq!(stored.id(), instrument.id());
3682    }
3683
3684    #[rstest]
3685    fn test_add_option_greeks_sub_defaults_to_both_conventions() {
3686        let client = OKXWebSocketClient::default();
3687        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3688
3689        client.add_option_greeks_sub(instrument_id);
3690
3691        let subs = client.option_greeks_subs().load();
3692        let stored = subs.get(&instrument_id).expect("instrument not registered");
3693        assert_eq!(stored.len(), 2);
3694        assert!(stored.contains(&OKXGreeksType::Bs));
3695        assert!(stored.contains(&OKXGreeksType::Pa));
3696    }
3697
3698    #[rstest]
3699    #[case::bs_only(vec![OKXGreeksType::Bs])]
3700    #[case::pa_only(vec![OKXGreeksType::Pa])]
3701    #[case::both(vec![OKXGreeksType::Bs, OKXGreeksType::Pa])]
3702    fn test_add_option_greeks_sub_with_conventions_stores_requested_set(
3703        #[case] conventions: Vec<OKXGreeksType>,
3704    ) {
3705        let client = OKXWebSocketClient::default();
3706        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3707        let set: AHashSet<OKXGreeksType> = conventions.iter().copied().collect();
3708
3709        client.add_option_greeks_sub_with_conventions(instrument_id, set.clone());
3710
3711        let subs = client.option_greeks_subs().load();
3712        let stored = subs.get(&instrument_id).expect("instrument not registered");
3713        assert_eq!(stored, &set);
3714    }
3715
3716    #[rstest]
3717    fn test_add_option_greeks_sub_with_empty_conventions_falls_back_to_both() {
3718        let client = OKXWebSocketClient::default();
3719        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3720
3721        client.add_option_greeks_sub_with_conventions(instrument_id, AHashSet::new());
3722
3723        let subs = client.option_greeks_subs().load();
3724        let stored = subs.get(&instrument_id).expect("instrument not registered");
3725        assert_eq!(stored.len(), 2);
3726    }
3727
3728    #[rstest]
3729    fn test_remove_option_greeks_sub_clears_entry() {
3730        let client = OKXWebSocketClient::default();
3731        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
3732
3733        client.add_option_greeks_sub(instrument_id);
3734        client.remove_option_greeks_sub(&instrument_id);
3735
3736        let subs = client.option_greeks_subs().load();
3737        assert!(!subs.contains_key(&instrument_id));
3738    }
3739
3740    #[rstest]
3741    fn test_new_with_credentials() {
3742        let client = OKXWebSocketClient::new(
3743            None,
3744            Some("test_key".to_string()),
3745            Some("test_secret".to_string()),
3746            Some("test_passphrase".to_string()),
3747            None,
3748            None,
3749            None,
3750            TransportBackend::default(),
3751            None,
3752        )
3753        .unwrap();
3754        assert!(client.credential.is_some());
3755        assert_eq!(client.api_key(), Some("test_key"));
3756    }
3757
3758    #[rstest]
3759    fn test_new_partial_credentials_fails() {
3760        let result = OKXWebSocketClient::new(
3761            None,
3762            Some("test_key".to_string()),
3763            None,
3764            Some("test_passphrase".to_string()),
3765            None,
3766            None,
3767            None,
3768            TransportBackend::default(),
3769            None,
3770        );
3771        result.unwrap_err();
3772    }
3773
3774    #[rstest]
3775    fn test_request_id_generation() {
3776        let client = OKXWebSocketClient::default();
3777
3778        let initial_counter = client.request_id_counter.load(Ordering::SeqCst);
3779
3780        let id1 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3781        let id2 = client.request_id_counter.fetch_add(1, Ordering::SeqCst);
3782
3783        assert_eq!(id1, initial_counter);
3784        assert_eq!(id2, initial_counter + 1);
3785        assert_eq!(
3786            client.request_id_counter.load(Ordering::SeqCst),
3787            initial_counter + 2
3788        );
3789    }
3790
3791    #[rstest]
3792    fn test_client_state_management() {
3793        let client = OKXWebSocketClient::default();
3794
3795        assert!(client.is_closed());
3796        assert!(!client.is_active());
3797
3798        let client_with_heartbeat = OKXWebSocketClient::new(
3799            None,
3800            None,
3801            None,
3802            None,
3803            None,
3804            Some(30),
3805            None,
3806            TransportBackend::default(),
3807            None,
3808        )
3809        .unwrap();
3810
3811        assert!(client_with_heartbeat.heartbeat.is_some());
3812        assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
3813    }
3814
3815    #[rstest]
3816    #[tokio::test]
3817    async fn begin_shutdown_stops_handler_before_bounded_close() {
3818        let client_id = ClientId::from("OKX-TEST");
3819        let endpoint = Ustr::from("okx-test-stream");
3820        let registry = SocketReconnectRegistry::default();
3821        let control =
3822            SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, &registry);
3823        let _sink = control.sink();
3824        control.register(|| SocketReconnectRequestOutcome::Accepted);
3825        let mut client = OKXWebSocketClient::default().with_socket_control(control);
3826        client
3827            .connection_mode
3828            .load()
3829            .store(ConnectionMode::Active.as_u8(), Ordering::SeqCst);
3830        let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
3831        let signal = DropSignal(Some(drop_tx));
3832        let handler_abort = CancellationToken::new();
3833        *client.handler_abort.lock() = handler_abort.clone();
3834        client
3835            .handler_tasks
3836            .spawn(async move {
3837                let _signal = signal;
3838                handler_abort.cancelled().await;
3839            })
3840            .expect("handler task should register");
3841
3842        assert!(registry.handle(client_id, endpoint).is_some());
3843        client.begin_shutdown();
3844
3845        tokio::time::timeout(Duration::from_secs(1), drop_rx)
3846            .await
3847            .expect("begin shutdown must drop the handler task")
3848            .expect("drop signal");
3849        assert!(client.is_closed());
3850        assert!(!client.handler_tasks.is_open());
3851        assert!(registry.handle(client_id, endpoint).is_some());
3852
3853        client.close().await.expect("bounded close");
3854        assert!(!client.has_task());
3855        assert!(registry.handle(client_id, endpoint).is_none());
3856    }
3857
3858    #[rstest]
3859    #[tokio::test]
3860    async fn connect_rollback_closes_handler_admission_and_deregisters_socket() {
3861        let client_id = ClientId::from("OKX-CONNECT-ROLLBACK");
3862        let endpoint = Ustr::from("okx-connect-rollback");
3863        let registry = SocketReconnectRegistry::default();
3864        let control =
3865            SocketControl::with_registry(client_id, Some(*OKX_VENUE), endpoint, &registry);
3866        control.register(|| SocketReconnectRequestOutcome::Accepted);
3867        let handler_tasks = Arc::new(TaskGroup::new());
3868        let signal = Arc::new(AtomicBool::new(false));
3869        let handler_abort = CancellationToken::new();
3870
3871        let rollback = ConnectRollback {
3872            handler_tasks: Arc::clone(&handler_tasks),
3873            signal: Arc::clone(&signal),
3874            handler_abort: handler_abort.clone(),
3875            socket_control: Some(Arc::new(control)),
3876            armed: true,
3877        };
3878
3879        drop(rollback);
3880
3881        assert!(!handler_tasks.is_open());
3882        assert!(signal.load(Ordering::Acquire));
3883        assert!(handler_abort.is_cancelled());
3884        assert!(registry.handle(client_id, endpoint).is_none());
3885        handler_tasks
3886            .finish_shutdown(Duration::ZERO, Duration::from_secs(1))
3887            .await
3888            .expect("empty handler scope should drain");
3889    }
3890
3891    #[rstest]
3892    #[tokio::test]
3893    async fn request_close_signals_before_handler_shutdown() {
3894        let mut client = OKXWebSocketClient::default();
3895        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3896        client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
3897        client.signal.store(false, Ordering::Release);
3898
3899        client.request_close().await;
3900
3901        assert!(client.signal.load(Ordering::Acquire));
3902        assert!(matches!(cmd_rx.try_recv(), Ok(HandlerCommand::Disconnect)));
3903    }
3904
3905    #[rstest]
3906    #[tokio::test]
3907    async fn close_joins_handler_shared_with_clone() {
3908        let mut client = OKXWebSocketClient::default();
3909        let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
3910        let signal = DropSignal(Some(drop_tx));
3911        client
3912            .handler_tasks
3913            .spawn(async move {
3914                let _signal = signal;
3915                std::future::pending::<()>().await;
3916            })
3917            .expect("handler task should register");
3918        let retained = client.clone();
3919
3920        client.close().await.expect("close with retained clone");
3921
3922        tokio::time::timeout(Duration::from_secs(1), drop_rx)
3923            .await
3924            .expect("close must drop the handler task")
3925            .expect("drop signal");
3926        assert!(!client.has_task());
3927        assert!(!retained.has_task());
3928    }
3929
3930    #[rstest]
3931    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3932    async fn timeout_retains_unfinished_handler_task() {
3933        let mut client = OKXWebSocketClient::default();
3934        let release = Arc::new((parking_lot::Mutex::new(false), parking_lot::Condvar::new()));
3935        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3936        let blocking_drop = BlockingDrop(Arc::clone(&release));
3937        client
3938            .handler_tasks
3939            .spawn(async move {
3940                let _blocking_drop = blocking_drop;
3941                started_tx.send(()).expect("started receiver");
3942                std::future::pending::<()>().await;
3943            })
3944            .expect("handler task should register");
3945        started_rx.await.expect("blocking task started");
3946        client.begin_shutdown();
3947
3948        let result = client.close_stream_task(Duration::from_millis(10)).await;
3949        let retained = client.has_task();
3950        let reconnect_result = client.connect().await;
3951
3952        let (lock, condvar) = &*release;
3953        *lock.lock() = true;
3954        condvar.notify_all();
3955
3956        client
3957            .close_stream_task(Duration::from_secs(1))
3958            .await
3959            .expect("blocking handler task terminated");
3960
3961        assert!(result.is_err());
3962        assert!(retained);
3963        assert_eq!(
3964            reconnect_result
3965                .expect_err("reconnect with unfinished handler")
3966                .to_string(),
3967            "Cannot connect while previous WebSocket handler task is still running"
3968        );
3969        assert!(!client.has_task());
3970    }
3971
3972    #[rstest]
3973    fn test_websocket_error_handling() {
3974        let clock = get_atomic_clock_realtime();
3975        let ts = clock.get_time_ns().as_u64();
3976
3977        let error = OKXWebSocketError {
3978            code: "60012".to_string(),
3979            message: "Invalid request".to_string(),
3980            conn_id: None,
3981            timestamp: ts,
3982        };
3983
3984        assert_eq!(error.code, "60012");
3985        assert_eq!(error.message, "Invalid request");
3986        assert_eq!(error.timestamp, ts);
3987
3988        let nautilus_msg = OKXWsMessage::Error(error);
3989        match nautilus_msg {
3990            OKXWsMessage::Error(e) => {
3991                assert_eq!(e.code, "60012");
3992                assert_eq!(e.message, "Invalid request");
3993            }
3994            _ => panic!("Expected Error variant"),
3995        }
3996    }
3997
3998    #[rstest]
3999    fn test_request_id_generation_sequence() {
4000        let client = OKXWebSocketClient::default();
4001
4002        let initial_counter = client
4003            .request_id_counter
4004            .load(std::sync::atomic::Ordering::SeqCst);
4005        let mut ids = Vec::new();
4006
4007        for _ in 0..10 {
4008            let id = client
4009                .request_id_counter
4010                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4011            ids.push(id);
4012        }
4013
4014        for (i, &id) in ids.iter().enumerate() {
4015            assert_eq!(id, initial_counter + i as u64);
4016        }
4017
4018        assert_eq!(
4019            client
4020                .request_id_counter
4021                .load(std::sync::atomic::Ordering::SeqCst),
4022            initial_counter + 10
4023        );
4024    }
4025
4026    #[rstest]
4027    fn test_client_state_transitions() {
4028        let client = OKXWebSocketClient::default();
4029
4030        assert!(client.is_closed());
4031        assert!(!client.is_active());
4032
4033        let client_with_heartbeat = OKXWebSocketClient::new(
4034            None,
4035            None,
4036            None,
4037            None,
4038            None,
4039            Some(30), // 30 second heartbeat
4040            None,
4041            TransportBackend::default(),
4042            None,
4043        )
4044        .unwrap();
4045
4046        assert!(client_with_heartbeat.heartbeat.is_some());
4047        assert_eq!(client_with_heartbeat.heartbeat.unwrap(), 30);
4048    }
4049
4050    #[rstest]
4051    fn test_websocket_error_scenarios() {
4052        let clock = get_atomic_clock_realtime();
4053        let ts = clock.get_time_ns().as_u64();
4054
4055        let error_scenarios = vec![
4056            ("60012", "Invalid request", None),
4057            ("60009", "Invalid API key", Some("conn-123".to_string())),
4058            ("60014", "Too many requests", None),
4059            ("50001", "Order not found", None),
4060        ];
4061
4062        for (code, message, conn_id) in error_scenarios {
4063            let error = OKXWebSocketError {
4064                code: code.to_string(),
4065                message: message.to_string(),
4066                conn_id: conn_id.clone(),
4067                timestamp: ts,
4068            };
4069
4070            assert_eq!(error.code, code);
4071            assert_eq!(error.message, message);
4072            assert_eq!(error.conn_id, conn_id);
4073            assert_eq!(error.timestamp, ts);
4074
4075            let nautilus_msg = OKXWsMessage::Error(error);
4076            match nautilus_msg {
4077                OKXWsMessage::Error(e) => {
4078                    assert_eq!(e.code, code);
4079                    assert_eq!(e.message, message);
4080                    assert_eq!(e.conn_id, conn_id);
4081                }
4082                _ => panic!("Expected Error variant"),
4083            }
4084        }
4085    }
4086
4087    #[rstest]
4088    fn test_feed_handler_reconnection_detection() {
4089        let msg = Message::Text(RECONNECTED.to_string().into());
4090        let result = OKXWsFeedHandler::parse_raw_message(msg);
4091        assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4092    }
4093
4094    #[rstest]
4095    fn test_feed_handler_normal_message_processing() {
4096        let ping_msg = Message::Text(TEXT_PING.to_string().into());
4097        let result = OKXWsFeedHandler::parse_raw_message(ping_msg);
4098        assert!(matches!(result, Some(OKXWsFrame::Ping)));
4099
4100        let sub_msg = r#"{
4101            "event": "subscribe",
4102            "arg": {
4103                "channel": "tickers",
4104                "instType": "SPOT"
4105            },
4106            "connId": "a4d3ae55"
4107        }"#;
4108
4109        let sub_result =
4110            OKXWsFeedHandler::parse_raw_message(Message::Text(sub_msg.to_string().into()));
4111        assert!(matches!(sub_result, Some(OKXWsFrame::Subscription { .. })));
4112    }
4113
4114    #[rstest]
4115    fn test_feed_handler_close_message() {
4116        let result = OKXWsFeedHandler::parse_raw_message(Message::Close(None));
4117        assert!(result.is_none());
4118    }
4119
4120    #[rstest]
4121    fn test_reconnection_message_constant() {
4122        assert_eq!(RECONNECTED, "__RECONNECTED__");
4123    }
4124
4125    #[rstest]
4126    fn test_multiple_reconnection_signals() {
4127        for _ in 0..3 {
4128            let msg = Message::Text(RECONNECTED.to_string().into());
4129            let result = OKXWsFeedHandler::parse_raw_message(msg);
4130            assert!(matches!(result, Some(OKXWsFrame::Reconnected)));
4131        }
4132    }
4133
4134    #[tokio::test]
4135    async fn test_wait_until_active_timeout() {
4136        let client = OKXWebSocketClient::new(
4137            None,
4138            Some("test_key".to_string()),
4139            Some("test_secret".to_string()),
4140            Some("test_passphrase".to_string()),
4141            Some(AccountId::from("test-account")),
4142            None,
4143            None,
4144            TransportBackend::default(),
4145            None,
4146        )
4147        .unwrap();
4148
4149        let result = client.wait_until_active(0.1).await;
4150
4151        assert!(result.is_err());
4152        assert!(!client.is_active());
4153    }
4154
4155    fn sample_canceled_order_msg() -> OKXOrderMsg {
4156        OKXOrderMsg {
4157            acc_fill_sz: Some("0".to_string()),
4158            avg_px: "0".to_string(),
4159            c_time: 0,
4160            cancel_source: None,
4161            cancel_source_reason: None,
4162            category: OKXOrderCategory::Normal,
4163            ccy: Ustr::from("USDT"),
4164            cl_ord_id: "order-1".to_string(),
4165            algo_cl_ord_id: None,
4166            attach_algo_cl_ord_id: None,
4167            attach_algo_ords: Vec::new(),
4168            outcome: None,
4169            fee: None,
4170            fee_ccy: Ustr::from("USDT"),
4171            fill_px: "0".to_string(),
4172            fill_sz: "0".to_string(),
4173            fill_time: 0,
4174            inst_id: Ustr::from("ETH-USDT-SWAP"),
4175            inst_type: OKXInstrumentType::Swap,
4176            lever: "1".to_string(),
4177            ord_id: Ustr::from("123456"),
4178            ord_type: OKXOrderType::Limit,
4179            pnl: "0".to_string(),
4180            pos_side: OKXPositionSide::Net,
4181            px: "0".to_string(),
4182            reduce_only: "false".to_string(),
4183            side: OKXSide::Buy,
4184            state: OKXOrderStatus::Canceled,
4185            exec_type: OKXExecType::None,
4186            sz: "1".to_string(),
4187            td_mode: OKXTradeMode::Cross,
4188            tgt_ccy: None,
4189            trade_id: String::new(),
4190            algo_id: None,
4191            fill_fee: None,
4192            fill_fee_ccy: None,
4193            fill_mark_px: None,
4194            fill_mark_vol: None,
4195            fill_px_vol: None,
4196            fill_px_usd: None,
4197            fill_fwd_px: None,
4198            fill_notional_usd: None,
4199            fill_pnl: None,
4200            is_tp_limit: None,
4201            linked_algo_ord: None,
4202            notional_usd: None,
4203            px_type: OKXPriceType::None,
4204            px_usd: None,
4205            px_vol: None,
4206            quick_mgn_type: OKXQuickMarginType::None,
4207            rebate: None,
4208            rebate_ccy: None,
4209            sl_ord_px: None,
4210            sl_trigger_px: None,
4211            sl_trigger_px_type: None,
4212            source: None,
4213            stp_id: None,
4214            stp_mode: OKXSelfTradePreventionMode::None,
4215            tag: None,
4216            tp_ord_px: None,
4217            tp_trigger_px: None,
4218            tp_trigger_px_type: None,
4219            amend_result: None,
4220            req_id: None,
4221            code: None,
4222            msg: None,
4223            u_time: 0,
4224        }
4225    }
4226
4227    #[rstest]
4228    fn test_is_post_only_auto_cancel_detects_cancel_source() {
4229        let mut msg = sample_canceled_order_msg();
4230        msg.cancel_source = Some(OKX_POST_ONLY_CANCEL_SOURCE.to_string());
4231
4232        assert!(is_post_only_auto_cancel(&msg));
4233    }
4234
4235    #[rstest]
4236    fn test_is_post_only_auto_cancel_detects_reason() {
4237        let mut msg = sample_canceled_order_msg();
4238        msg.cancel_source_reason = Some("POST_ONLY would take liquidity".to_string());
4239
4240        assert!(is_post_only_auto_cancel(&msg));
4241    }
4242
4243    #[rstest]
4244    fn test_is_post_only_auto_cancel_false_without_markers() {
4245        let msg = sample_canceled_order_msg();
4246
4247        assert!(!is_post_only_auto_cancel(&msg));
4248    }
4249
4250    #[rstest]
4251    fn test_is_post_only_auto_cancel_false_for_order_type_only() {
4252        let mut msg = sample_canceled_order_msg();
4253        msg.ord_type = OKXOrderType::PostOnly;
4254
4255        assert!(!is_post_only_auto_cancel(&msg));
4256    }
4257
4258    #[tokio::test]
4259    async fn test_batch_cancel_orders_with_multiple_orders() {
4260        use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4261
4262        let client = OKXWebSocketClient::new(
4263            Some("wss://test.okx.com".to_string()),
4264            None,
4265            None,
4266            None,
4267            None,
4268            None,
4269            None,
4270            TransportBackend::default(),
4271            None,
4272        )
4273        .expect("Failed to create client");
4274
4275        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4276        let client_order_id1 = ClientOrderId::new("order1");
4277        let client_order_id2 = ClientOrderId::new("order2");
4278        let venue_order_id1 = VenueOrderId::new("venue1");
4279        let venue_order_id2 = VenueOrderId::new("venue2");
4280
4281        let orders = vec![
4282            (instrument_id, Some(client_order_id1), Some(venue_order_id1)),
4283            (instrument_id, Some(client_order_id2), Some(venue_order_id2)),
4284        ];
4285
4286        let result = client.batch_cancel_orders(orders).await;
4287        assert!(result.is_err());
4288    }
4289
4290    #[tokio::test]
4291    async fn test_batch_cancel_orders_with_only_client_order_id() {
4292        use nautilus_model::identifiers::{ClientOrderId, InstrumentId};
4293
4294        let client = OKXWebSocketClient::new(
4295            Some("wss://test.okx.com".to_string()),
4296            None,
4297            None,
4298            None,
4299            None,
4300            None,
4301            None,
4302            TransportBackend::default(),
4303            None,
4304        )
4305        .expect("Failed to create client");
4306
4307        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4308        let client_order_id = ClientOrderId::new("order1");
4309
4310        let orders = vec![(instrument_id, Some(client_order_id), None)];
4311
4312        let result = client.batch_cancel_orders(orders).await;
4313
4314        assert!(result.is_err());
4315    }
4316
4317    #[tokio::test]
4318    async fn test_batch_cancel_orders_with_only_venue_order_id() {
4319        use nautilus_model::identifiers::{InstrumentId, VenueOrderId};
4320
4321        let client = OKXWebSocketClient::new(
4322            Some("wss://test.okx.com".to_string()),
4323            None,
4324            None,
4325            None,
4326            None,
4327            None,
4328            None,
4329            TransportBackend::default(),
4330            None,
4331        )
4332        .expect("Failed to create client");
4333
4334        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4335        let venue_order_id = VenueOrderId::new("venue1");
4336
4337        let orders = vec![(instrument_id, None, Some(venue_order_id))];
4338
4339        let result = client.batch_cancel_orders(orders).await;
4340
4341        assert!(result.is_err());
4342    }
4343
4344    #[tokio::test]
4345    async fn test_batch_cancel_orders_with_both_ids() {
4346        use nautilus_model::identifiers::{ClientOrderId, InstrumentId, VenueOrderId};
4347
4348        let client = OKXWebSocketClient::new(
4349            Some("wss://test.okx.com".to_string()),
4350            None,
4351            None,
4352            None,
4353            None,
4354            None,
4355            None,
4356            TransportBackend::default(),
4357            None,
4358        )
4359        .expect("Failed to create client");
4360
4361        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4362        let client_order_id = ClientOrderId::new("order1");
4363        let venue_order_id = VenueOrderId::new("venue1");
4364
4365        let orders = vec![(instrument_id, Some(client_order_id), Some(venue_order_id))];
4366
4367        let result = client.batch_cancel_orders(orders).await;
4368
4369        assert!(result.is_err());
4370    }
4371
4372    #[tokio::test]
4373    async fn test_cancel_order_fails_without_inst_id_code() {
4374        use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4375
4376        let client = OKXWebSocketClient::default();
4377        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4378
4379        let result = client
4380            .cancel_order(
4381                TraderId::from("TESTER-001"),
4382                StrategyId::from("S-001"),
4383                instrument_id,
4384                Some(ClientOrderId::new("O-001")),
4385                None,
4386            )
4387            .await;
4388
4389        assert!(result.is_err());
4390        let err = result.unwrap_err().to_string();
4391        assert!(
4392            err.contains("No instIdCode cached for BTC-USDT-SWAP.OKX"),
4393            "Expected instIdCode error, found: {err}"
4394        );
4395    }
4396
4397    #[tokio::test]
4398    async fn test_submit_order_fails_without_inst_id_code() {
4399        use nautilus_model::{
4400            enums::{OrderSide, OrderType},
4401            identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
4402            types::Quantity,
4403        };
4404
4405        use crate::common::enums::OKXTradeMode;
4406
4407        let client = OKXWebSocketClient::default();
4408        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
4409
4410        let result = client
4411            .submit_order(
4412                TraderId::from("TESTER-001"),
4413                StrategyId::from("S-001"),
4414                instrument_id,
4415                OKXTradeMode::Cross,
4416                ClientOrderId::new("O-001"),
4417                OrderSide::Buy,
4418                OrderType::Limit,
4419                Quantity::from("0.01"),
4420                None,
4421                None,
4422                None,
4423                None,
4424                None,
4425                None,
4426                None,
4427                None,
4428                None,
4429                None,
4430                None,
4431                None,
4432                None,
4433                None,
4434                None,
4435                None,
4436            )
4437            .await;
4438
4439        assert!(result.is_err());
4440        let err = result.unwrap_err().to_string();
4441        assert!(
4442            err.contains("No instIdCode cached for ETH-USDT-SWAP.OKX"),
4443            "Expected instIdCode error, found: {err}"
4444        );
4445    }
4446
4447    #[tokio::test]
4448    async fn test_cancel_order_passes_inst_id_code_lookup_when_cached() {
4449        use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId};
4450        use ustr::Ustr;
4451
4452        let client = OKXWebSocketClient::default();
4453        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4454
4455        // Populate the cache so the lookup succeeds
4456        client.cache_inst_id_code(Ustr::from("BTC-USDT-SWAP"), 10459);
4457
4458        let result = client
4459            .cancel_order(
4460                TraderId::from("TESTER-001"),
4461                StrategyId::from("S-001"),
4462                instrument_id,
4463                Some(ClientOrderId::new("O-001")),
4464                None,
4465            )
4466            .await;
4467
4468        // Fails later (not connected) rather than at instIdCode lookup
4469        assert!(result.is_err());
4470        let err = result.unwrap_err().to_string();
4471        assert!(
4472            !err.contains("No instIdCode cached"),
4473            "Should pass instIdCode lookup, found: {err}"
4474        );
4475    }
4476
4477    #[rstest]
4478    fn test_race_unsubscribe_failure_recovery() {
4479        // Simulates the race condition where venue rejects an unsubscribe request.
4480        // The adapter must perform the 3-step recovery:
4481        // 1. confirm_unsubscribe() - clear pending_unsubscribe
4482        // 2. mark_subscribe() - mark as subscribing again
4483        // 3. confirm_subscribe() - restore to confirmed state
4484        let client = OKXWebSocketClient::new(
4485            Some("wss://test.okx.com".to_string()),
4486            None,
4487            None,
4488            None,
4489            None,
4490            None,
4491            None,
4492            TransportBackend::default(),
4493            None,
4494        )
4495        .expect("Failed to create client");
4496
4497        let topic = "trades:BTC-USDT-SWAP";
4498
4499        // Initial subscribe flow
4500        client.subscriptions_state.mark_subscribe(topic);
4501        client.subscriptions_state.confirm_subscribe(topic);
4502        assert_eq!(client.subscriptions_state.len(), 1);
4503
4504        // User unsubscribes
4505        client.subscriptions_state.mark_unsubscribe(topic);
4506        assert_eq!(client.subscriptions_state.len(), 0);
4507        assert_eq!(
4508            client.subscriptions_state.pending_unsubscribe_topics(),
4509            vec![topic]
4510        );
4511
4512        // Venue REJECTS the unsubscribe (error message)
4513        // Adapter must perform 3-step recovery (from lines 4444-4446)
4514        client.subscriptions_state.confirm_unsubscribe(topic); // Step 1: clear pending_unsubscribe
4515        client.subscriptions_state.mark_subscribe(topic); // Step 2: mark as subscribing
4516        client.subscriptions_state.confirm_subscribe(topic); // Step 3: confirm subscription
4517
4518        // Verify recovery: topic should be back in confirmed state
4519        assert_eq!(client.subscriptions_state.len(), 1);
4520        assert!(
4521            client
4522                .subscriptions_state
4523                .pending_unsubscribe_topics()
4524                .is_empty()
4525        );
4526        assert!(
4527            client
4528                .subscriptions_state
4529                .pending_subscribe_topics()
4530                .is_empty()
4531        );
4532
4533        // Verify topic is in all_topics() for reconnect
4534        let all = client.subscriptions_state.all_topics();
4535        assert_eq!(all.len(), 1);
4536        assert!(all.contains(&topic.to_string()));
4537    }
4538
4539    #[rstest]
4540    fn test_race_resubscribe_before_unsubscribe_ack() {
4541        // Simulates: User unsubscribes, then immediately resubscribes before
4542        // the unsubscribe ACK arrives from the venue.
4543        // This is the race condition fixed in the subscription tracker.
4544        let client = OKXWebSocketClient::new(
4545            Some("wss://test.okx.com".to_string()),
4546            None,
4547            None,
4548            None,
4549            None,
4550            None,
4551            None,
4552            TransportBackend::default(),
4553            None,
4554        )
4555        .expect("Failed to create client");
4556
4557        let topic = "books:BTC-USDT";
4558
4559        // Initial subscribe
4560        client.subscriptions_state.mark_subscribe(topic);
4561        client.subscriptions_state.confirm_subscribe(topic);
4562        assert_eq!(client.subscriptions_state.len(), 1);
4563
4564        // User unsubscribes
4565        client.subscriptions_state.mark_unsubscribe(topic);
4566        assert_eq!(client.subscriptions_state.len(), 0);
4567        assert_eq!(
4568            client.subscriptions_state.pending_unsubscribe_topics(),
4569            vec![topic]
4570        );
4571
4572        // User immediately changes mind and resubscribes (before unsubscribe ACK)
4573        client.subscriptions_state.mark_subscribe(topic);
4574        assert_eq!(
4575            client.subscriptions_state.pending_subscribe_topics(),
4576            vec![topic]
4577        );
4578
4579        // NOW the unsubscribe ACK arrives - should NOT clear pending_subscribe
4580        client.subscriptions_state.confirm_unsubscribe(topic);
4581        assert!(
4582            client
4583                .subscriptions_state
4584                .pending_unsubscribe_topics()
4585                .is_empty()
4586        );
4587        assert_eq!(
4588            client.subscriptions_state.pending_subscribe_topics(),
4589            vec![topic]
4590        );
4591
4592        // Subscribe ACK arrives
4593        client.subscriptions_state.confirm_subscribe(topic);
4594        assert_eq!(client.subscriptions_state.len(), 1);
4595        assert!(
4596            client
4597                .subscriptions_state
4598                .pending_subscribe_topics()
4599                .is_empty()
4600        );
4601
4602        // Verify final state is correct
4603        let all = client.subscriptions_state.all_topics();
4604        assert_eq!(all.len(), 1);
4605        assert!(all.contains(&topic.to_string()));
4606    }
4607
4608    #[rstest]
4609    fn test_race_late_subscribe_confirmation_after_unsubscribe() {
4610        // Simulates: User subscribes, then unsubscribes before subscribe ACK arrives.
4611        // The late subscribe ACK should be ignored.
4612        let client = OKXWebSocketClient::new(
4613            Some("wss://test.okx.com".to_string()),
4614            None,
4615            None,
4616            None,
4617            None,
4618            None,
4619            None,
4620            TransportBackend::default(),
4621            None,
4622        )
4623        .expect("Failed to create client");
4624
4625        let topic = "tickers:ETH-USDT";
4626
4627        // User subscribes
4628        client.subscriptions_state.mark_subscribe(topic);
4629        assert_eq!(
4630            client.subscriptions_state.pending_subscribe_topics(),
4631            vec![topic]
4632        );
4633
4634        // User immediately unsubscribes (before subscribe ACK)
4635        client.subscriptions_state.mark_unsubscribe(topic);
4636        assert!(
4637            client
4638                .subscriptions_state
4639                .pending_subscribe_topics()
4640                .is_empty()
4641        ); // Cleared
4642        assert_eq!(
4643            client.subscriptions_state.pending_unsubscribe_topics(),
4644            vec![topic]
4645        );
4646
4647        // Late subscribe confirmation arrives - should be IGNORED
4648        client.subscriptions_state.confirm_subscribe(topic);
4649        assert_eq!(client.subscriptions_state.len(), 0); // Not added to confirmed
4650        assert_eq!(
4651            client.subscriptions_state.pending_unsubscribe_topics(),
4652            vec![topic]
4653        );
4654
4655        // Unsubscribe ACK arrives
4656        client.subscriptions_state.confirm_unsubscribe(topic);
4657
4658        // Final state: completely empty
4659        assert!(client.subscriptions_state.is_empty());
4660        assert!(client.subscriptions_state.all_topics().is_empty());
4661    }
4662
4663    #[rstest]
4664    fn test_race_reconnection_with_pending_states() {
4665        // Simulates reconnection with mixed pending states.
4666        let client = OKXWebSocketClient::new(
4667            Some("wss://test.okx.com".to_string()),
4668            Some("test_key".to_string()),
4669            Some("test_secret".to_string()),
4670            Some("test_passphrase".to_string()),
4671            Some(AccountId::new("OKX-TEST")),
4672            None,
4673            None,
4674            TransportBackend::default(),
4675            None,
4676        )
4677        .expect("Failed to create client");
4678
4679        // Set up mixed state before reconnection
4680        // Confirmed: trades:BTC-USDT-SWAP
4681        let trade_btc = "trades:BTC-USDT-SWAP";
4682        client.subscriptions_state.mark_subscribe(trade_btc);
4683        client.subscriptions_state.confirm_subscribe(trade_btc);
4684
4685        // Pending subscribe: trades:ETH-USDT-SWAP
4686        let trade_eth = "trades:ETH-USDT-SWAP";
4687        client.subscriptions_state.mark_subscribe(trade_eth);
4688
4689        // Pending unsubscribe: books:BTC-USDT (user cancelled)
4690        let book_btc = "books:BTC-USDT";
4691        client.subscriptions_state.mark_subscribe(book_btc);
4692        client.subscriptions_state.confirm_subscribe(book_btc);
4693        client.subscriptions_state.mark_unsubscribe(book_btc);
4694
4695        // Get topics for reconnection
4696        let topics_to_restore = client.subscriptions_state.all_topics();
4697
4698        // Should include: confirmed + pending_subscribe (NOT pending_unsubscribe)
4699        assert_eq!(topics_to_restore.len(), 2);
4700        assert!(topics_to_restore.contains(&trade_btc.to_string()));
4701        assert!(topics_to_restore.contains(&trade_eth.to_string()));
4702        assert!(!topics_to_restore.contains(&book_btc.to_string())); // Excluded
4703    }
4704
4705    #[rstest]
4706    fn test_race_duplicate_subscribe_messages_idempotent() {
4707        // Simulates duplicate subscribe requests (e.g., from reconnection logic).
4708        // The subscription tracker should be idempotent and not create duplicate state.
4709        let client = OKXWebSocketClient::new(
4710            Some("wss://test.okx.com".to_string()),
4711            None,
4712            None,
4713            None,
4714            None,
4715            None,
4716            None,
4717            TransportBackend::default(),
4718            None,
4719        )
4720        .expect("Failed to create client");
4721
4722        let topic = "trades:BTC-USDT-SWAP";
4723
4724        // Subscribe and confirm
4725        client.subscriptions_state.mark_subscribe(topic);
4726        client.subscriptions_state.confirm_subscribe(topic);
4727        assert_eq!(client.subscriptions_state.len(), 1);
4728
4729        // Duplicate mark_subscribe on already-confirmed topic (should be no-op)
4730        client.subscriptions_state.mark_subscribe(topic);
4731        assert!(
4732            client
4733                .subscriptions_state
4734                .pending_subscribe_topics()
4735                .is_empty()
4736        ); // Not re-added
4737        assert_eq!(client.subscriptions_state.len(), 1); // Still just 1
4738
4739        // Duplicate confirm_subscribe (should be idempotent)
4740        client.subscriptions_state.confirm_subscribe(topic);
4741        assert_eq!(client.subscriptions_state.len(), 1);
4742
4743        // Verify final state
4744        let all = client.subscriptions_state.all_topics();
4745        assert_eq!(all.len(), 1);
4746        assert_eq!(all[0], topic);
4747    }
4748}