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