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