Skip to main content

nautilus_okx/websocket/
client.rs

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