Skip to main content

nautilus_bybit/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//! Bybit WebSocket client providing public market data streaming.
17//!
18//! Bybit API reference <https://bybit-exchange.github.io/docs/>.
19
20use std::{
21    collections::HashSet,
22    fmt::Debug,
23    num::NonZeroU32,
24    sync::{
25        Arc,
26        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
27    },
28    time::Duration,
29};
30
31use arc_swap::ArcSwap;
32#[cfg(test)]
33use nautilus_common::live::get_runtime;
34use nautilus_core::{AtomicMap, AtomicSet, UUID4, string::secret::SecretString};
35use nautilus_live::{
36    SocketControl,
37    book::snapshot::SnapshotGate,
38    task::{SharedTaskSlot, TaskJoinOutcome},
39};
40use nautilus_model::{
41    data::BarType,
42    enums::{AggregationSource, OrderSide, OrderType, PriceType, TimeInForce, TriggerType},
43    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
44    instruments::{Instrument, InstrumentAny},
45    types::{Price, Quantity},
46};
47use nautilus_network::{
48    ConnectionUrl,
49    http::create_standard_nautilus_headers,
50    mode::ConnectionMode,
51    ratelimiter::{RateLimiter, clock::MonotonicClock},
52    websocket::{
53        AuthTracker, InitialConnectRetryPolicy, SubscriptionState, TransportBackend,
54        WebSocketClient, WebSocketConfig, channel_message_handler,
55    },
56};
57use serde_json::Value;
58use tokio_util::sync::CancellationToken;
59use ustr::Ustr;
60use zeroize::Zeroizing;
61
62use crate::{
63    common::{
64        consts::{BYBIT_NAUTILUS_BROKER_ID, BYBIT_WS_TOPIC_DELIMITER},
65        credential::Credential,
66        enums::{
67            BybitBboSideType, BybitEnvironment, BybitOrderSide, BybitOrderSmpType, BybitOrderType,
68            BybitPositionIdx, BybitProductType, BybitTimeInForce, BybitTpSlMode,
69            BybitWsOrderRequestOp, resolve_trigger_type,
70        },
71        parse::{
72            bar_spec_to_bybit_interval, extract_base_coin, extract_raw_symbol, map_time_in_force,
73            spot_leverage, spot_market_unit, trigger_direction,
74        },
75        rate_limit::{
76            BYBIT_OPTION_SUBSCRIPTION_LIMIT, BybitRateLimiter, batch_send_limit, batch_weight,
77            websocket_connection_key, websocket_connection_limiter,
78        },
79        symbol::BybitSymbol,
80        urls::{bybit_ws_private_url, bybit_ws_public_url, bybit_ws_trade_url},
81    },
82    websocket::{
83        enums::{BybitWsOperation, BybitWsPrivateChannel, BybitWsPublicChannel},
84        error::{BybitWsError, BybitWsResult},
85        handler::{BybitWsFeedHandler, BybitWsOrderCommand, HandlerCommand},
86        messages::{
87            BybitAuthRequest, BybitSubscription, BybitWsAmendOrderParams, BybitWsBatchAmendItem,
88            BybitWsBatchAmendOrderArgs, BybitWsBatchCancelItem, BybitWsBatchCancelOrderArgs,
89            BybitWsBatchPlaceItem, BybitWsBatchPlaceOrderArgs, BybitWsCancelOrderParams,
90            BybitWsMessage, BybitWsPlaceOrderParams,
91        },
92    },
93};
94
95const WEBSOCKET_AUTH_WINDOW_MS: i64 = 5_000;
96const AUTH_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
97/// Legacy non-Spot batch endpoint maximum.
98pub const BATCH_PROCESSING_LIMIT: usize = 20;
99/// Public/market data WebSocket client for Bybit.
100pub struct BybitWebSocketClient {
101    url: ConnectionUrl,
102    environment: BybitEnvironment,
103    product_type: Option<BybitProductType>,
104    credential: Option<Credential>,
105    requires_auth: bool,
106    auth_tracker: AuthTracker,
107    heartbeat: Option<u64>,
108    auth_wait_timeout: Duration,
109    connection_mode: Arc<ArcSwap<AtomicU8>>,
110    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
111    out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<BybitWsMessage>>>,
112    signal: Arc<AtomicBool>,
113    task_handle: Arc<SharedTaskSlot<()>>,
114    connect_lock: Arc<tokio::sync::Mutex<()>>,
115    subscriptions: SubscriptionState,
116    subscription_guard: Arc<tokio::sync::Mutex<()>>,
117    rate_limiter: BybitRateLimiter,
118    recv_window_ms: Arc<AtomicU64>,
119    account_id: Option<AccountId>,
120    mm_level: Arc<AtomicU8>,
121    bar_types_cache: Arc<AtomicMap<String, BarType>>,
122    instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
123    trade_subs: Arc<AtomicSet<InstrumentId>>,
124    option_greeks_subs: Arc<AtomicSet<InstrumentId>>,
125    transport_backend: TransportBackend,
126    cancellation_token: Arc<ArcSwap<CancellationToken>>,
127    proxy_url: Option<SecretString>,
128    socket_control: Option<SocketControl>,
129}
130
131struct ConnectRollback {
132    signal: Arc<AtomicBool>,
133    cancellation_token: Arc<ArcSwap<CancellationToken>>,
134    task_handle: Arc<SharedTaskSlot<()>>,
135    armed: bool,
136}
137
138impl ConnectRollback {
139    fn new(client: &BybitWebSocketClient) -> Self {
140        Self {
141            signal: Arc::clone(&client.signal),
142            cancellation_token: Arc::clone(&client.cancellation_token),
143            task_handle: Arc::clone(&client.task_handle),
144            armed: true,
145        }
146    }
147
148    fn disarm(&mut self) {
149        self.armed = false;
150    }
151}
152
153impl Drop for ConnectRollback {
154    fn drop(&mut self) {
155        if self.armed {
156            self.signal.store(true, Ordering::Release);
157            self.cancellation_token.load().cancel();
158            self.task_handle.abort();
159        }
160    }
161}
162
163impl Debug for BybitWebSocketClient {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct(stringify!(BybitWebSocketClient))
166            .field("url", &self.url)
167            .field("environment", &self.environment)
168            .field("product_type", &self.product_type)
169            .field("requires_auth", &self.requires_auth)
170            .field("heartbeat", &self.heartbeat)
171            .field("confirmed_subscriptions", &self.subscriptions.len())
172            .finish()
173    }
174}
175
176impl Clone for BybitWebSocketClient {
177    fn clone(&self) -> Self {
178        Self {
179            url: self.url.clone(),
180            environment: self.environment,
181            product_type: self.product_type,
182            credential: self.credential.clone(),
183            requires_auth: self.requires_auth,
184            auth_tracker: self.auth_tracker.clone(),
185            heartbeat: self.heartbeat,
186            auth_wait_timeout: self.auth_wait_timeout,
187            connection_mode: Arc::clone(&self.connection_mode),
188            cmd_tx: Arc::clone(&self.cmd_tx),
189            out_rx: None, // Each clone gets its own receiver
190            signal: Arc::clone(&self.signal),
191            task_handle: Arc::clone(&self.task_handle),
192            connect_lock: Arc::clone(&self.connect_lock),
193            subscriptions: self.subscriptions.clone(),
194            subscription_guard: Arc::clone(&self.subscription_guard),
195            rate_limiter: self.rate_limiter.clone(),
196            recv_window_ms: Arc::clone(&self.recv_window_ms),
197            account_id: self.account_id,
198            mm_level: Arc::clone(&self.mm_level),
199            bar_types_cache: Arc::clone(&self.bar_types_cache),
200            instruments_cache: Arc::clone(&self.instruments_cache),
201            trade_subs: Arc::clone(&self.trade_subs),
202            option_greeks_subs: Arc::clone(&self.option_greeks_subs),
203            transport_backend: self.transport_backend,
204            cancellation_token: Arc::clone(&self.cancellation_token),
205            proxy_url: self.proxy_url.clone(),
206            socket_control: self.socket_control.clone(),
207        }
208    }
209}
210
211impl BybitWebSocketClient {
212    fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
213        InitialConnectRetryPolicy {
214            max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
215            delay_initial: Duration::from_millis(500),
216            delay_max: Duration::from_secs(5),
217            backoff_factor: 2.0,
218            jitter_ms: 250,
219        }
220    }
221
222    /// Creates a new Bybit public WebSocket client.
223    #[must_use]
224    pub fn new_public(url: Option<String>, heartbeat: u64) -> Self {
225        Self::new_public_with(
226            BybitProductType::Linear,
227            BybitEnvironment::Mainnet,
228            url,
229            heartbeat,
230            TransportBackend::default(),
231            None,
232        )
233    }
234
235    /// Sets the timeout for waiting on (re)authentication before failing an
236    /// authenticated operation. Defaults to `AUTH_WAIT_TIMEOUT` (5s).
237    pub fn set_auth_wait_timeout(&mut self, timeout: Duration) {
238        self.auth_wait_timeout = timeout;
239    }
240
241    /// Sets the receive window sent with WebSocket trade commands.
242    pub fn set_recv_window_ms(&self, recv_window_ms: u64) {
243        self.recv_window_ms.store(recv_window_ms, Ordering::Release);
244    }
245
246    /// Creates a new Bybit public WebSocket client targeting the specified product/environment.
247    #[must_use]
248    pub fn new_public_with(
249        product_type: BybitProductType,
250        environment: BybitEnvironment,
251        url: Option<String>,
252        heartbeat: u64,
253        transport_backend: TransportBackend,
254        proxy_url: Option<String>,
255    ) -> Self {
256        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
257
258        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
259        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
260        let resolved_url = url.unwrap_or_else(|| bybit_ws_public_url(product_type, environment));
261        let rate_limiter =
262            BybitRateLimiter::for_websocket(&resolved_url, None, proxy_url.as_deref());
263
264        Self {
265            url: resolved_url.into(),
266            environment,
267            product_type: Some(product_type),
268            credential: None,
269            requires_auth: false,
270            auth_tracker: AuthTracker::new(),
271            heartbeat: Some(heartbeat),
272            auth_wait_timeout: AUTH_WAIT_TIMEOUT,
273            connection_mode,
274            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
275            out_rx: None,
276            signal: Arc::new(AtomicBool::new(false)),
277            task_handle: Arc::new(SharedTaskSlot::new()),
278            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
279            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
280            subscription_guard: Arc::new(tokio::sync::Mutex::new(())),
281            rate_limiter,
282            recv_window_ms: Arc::new(AtomicU64::new(5_000)),
283            bar_types_cache: Arc::new(AtomicMap::new()),
284            instruments_cache: Arc::new(AtomicMap::new()),
285            trade_subs: Arc::new(AtomicSet::new()),
286            option_greeks_subs: Arc::new(AtomicSet::new()),
287            account_id: None,
288            mm_level: Arc::new(AtomicU8::new(0)),
289            transport_backend,
290            cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
291            proxy_url: proxy_url.map(SecretString::from),
292            socket_control: None,
293        }
294    }
295
296    /// Configures socket state reporting and reconnect control.
297    #[must_use]
298    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
299        self.socket_control = Some(control);
300        self
301    }
302
303    /// Creates a new Bybit private WebSocket client.
304    ///
305    /// If `api_key` or `api_secret` are not provided, they will be loaded from
306    /// environment variables based on the environment:
307    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
308    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
309    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
310    #[must_use]
311    pub fn new_private(
312        environment: BybitEnvironment,
313        api_key: Option<String>,
314        api_secret: Option<String>,
315        url: Option<String>,
316        heartbeat: u64,
317        transport_backend: TransportBackend,
318        proxy_url: Option<String>,
319    ) -> Self {
320        let credential = Credential::resolve(api_key, api_secret, environment);
321
322        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
323
324        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
325        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
326        let resolved_url = url.unwrap_or_else(|| bybit_ws_private_url(environment).to_string());
327        let rate_limiter = BybitRateLimiter::for_websocket(
328            &resolved_url,
329            credential.as_ref().map(Credential::api_key),
330            proxy_url.as_deref(),
331        );
332
333        Self {
334            url: resolved_url.into(),
335            environment,
336            product_type: None,
337            credential,
338            requires_auth: true,
339            auth_tracker: AuthTracker::new(),
340            heartbeat: Some(heartbeat),
341            auth_wait_timeout: AUTH_WAIT_TIMEOUT,
342            connection_mode,
343            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
344            out_rx: None,
345            signal: Arc::new(AtomicBool::new(false)),
346            task_handle: Arc::new(SharedTaskSlot::new()),
347            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
348            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
349            subscription_guard: Arc::new(tokio::sync::Mutex::new(())),
350            rate_limiter,
351            recv_window_ms: Arc::new(AtomicU64::new(5_000)),
352            bar_types_cache: Arc::new(AtomicMap::new()),
353            instruments_cache: Arc::new(AtomicMap::new()),
354            trade_subs: Arc::new(AtomicSet::new()),
355            option_greeks_subs: Arc::new(AtomicSet::new()),
356            account_id: None,
357            mm_level: Arc::new(AtomicU8::new(0)),
358            transport_backend,
359            cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
360            proxy_url: proxy_url.map(SecretString::from),
361            socket_control: None,
362        }
363    }
364
365    /// Creates a new Bybit trade WebSocket client for order operations.
366    ///
367    /// If `api_key` or `api_secret` are not provided, they will be loaded from
368    /// environment variables based on the environment:
369    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
370    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
371    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
372    #[must_use]
373    pub fn new_trade(
374        environment: BybitEnvironment,
375        api_key: Option<String>,
376        api_secret: Option<String>,
377        url: Option<String>,
378        heartbeat: u64,
379        transport_backend: TransportBackend,
380        proxy_url: Option<String>,
381    ) -> Self {
382        let credential = Credential::resolve(api_key, api_secret, environment);
383
384        let (cmd_tx, _) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
385
386        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
387        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
388        let resolved_url = url.unwrap_or_else(|| bybit_ws_trade_url(environment).to_string());
389        let rate_limiter = BybitRateLimiter::for_websocket(
390            &resolved_url,
391            credential.as_ref().map(Credential::api_key),
392            proxy_url.as_deref(),
393        );
394
395        Self {
396            url: resolved_url.into(),
397            environment,
398            product_type: None,
399            credential,
400            requires_auth: true,
401            auth_tracker: AuthTracker::new(),
402            heartbeat: Some(heartbeat),
403            auth_wait_timeout: AUTH_WAIT_TIMEOUT,
404            connection_mode,
405            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
406            out_rx: None,
407            signal: Arc::new(AtomicBool::new(false)),
408            task_handle: Arc::new(SharedTaskSlot::new()),
409            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
410            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
411            subscription_guard: Arc::new(tokio::sync::Mutex::new(())),
412            rate_limiter,
413            recv_window_ms: Arc::new(AtomicU64::new(5_000)),
414            bar_types_cache: Arc::new(AtomicMap::new()),
415            instruments_cache: Arc::new(AtomicMap::new()),
416            trade_subs: Arc::new(AtomicSet::new()),
417            option_greeks_subs: Arc::new(AtomicSet::new()),
418            account_id: None,
419            mm_level: Arc::new(AtomicU8::new(0)),
420            transport_backend,
421            cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
422            proxy_url: proxy_url.map(SecretString::from),
423            socket_control: None,
424        }
425    }
426
427    pub(crate) fn begin_shutdown(&self) {
428        self.cancellation_token.load().cancel();
429        self.signal.store(true, Ordering::Release);
430    }
431
432    /// Establishes the WebSocket connection.
433    ///
434    /// Clears subscription state from earlier sessions, so callers resubscribe after connecting.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the underlying WebSocket connection cannot be established,
439    /// after retrying multiple times with exponential backoff.
440    pub async fn connect(&mut self) -> BybitWsResult<()> {
441        let connect_lock = Arc::clone(&self.connect_lock);
442        let _guard = connect_lock.lock().await;
443        self.connect_locked().await
444    }
445
446    async fn connect_locked(&mut self) -> BybitWsResult<()> {
447        if !self.task_handle.is_empty() {
448            self.close_locked().await?;
449        }
450        self.signal.store(false, Ordering::Relaxed);
451        let cancellation_token = CancellationToken::new();
452        self.cancellation_token
453            .store(Arc::new(cancellation_token.clone()));
454
455        let (raw_handler, raw_rx) = channel_message_handler();
456
457        // Inbound Ping frames are answered by the transport, so no ping handler is needed;
458        // the reader routes them away from the message channel and the handler never sees them.
459
460        let ping_msg = serde_json::to_string(&BybitSubscription {
461            op: BybitWsOperation::Ping,
462            args: vec![],
463            req_id: None,
464        })?;
465
466        let config = WebSocketConfig {
467            url: self.url.expose().to_owned(),
468            headers: Self::default_headers(),
469            heartbeat_interval_secs: self.heartbeat,
470            heartbeat_payload: Some(ping_msg),
471            connect_timeout_ms: Some(5_000),
472            reconnect_delay_initial_ms: Some(500),
473            reconnect_delay_max_ms: Some(5_000),
474            reconnect_backoff_factor: Some(1.5),
475            reconnect_jitter_ms: Some(250),
476            reconnect_max_attempts: None,
477            heartbeat_timeout_secs: None,
478            idle_timeout_ms: None,
479            writer_capacity: None,
480            backend: self.transport_backend,
481            proxy_url: self
482                .proxy_url
483                .as_ref()
484                .map(|value| value.expose_secret().to_owned()),
485            max_message_size_bytes: None,
486            max_frame_size_bytes: None,
487        };
488
489        let message_rate_limiter = Arc::new(RateLimiter::<Ustr, MonotonicClock>::new_with_quota(
490            None,
491            vec![],
492        ));
493        let connection_rate_limiter = websocket_connection_limiter(
494            self.url.expose(),
495            self.proxy_url.as_ref().map(SecretString::expose_secret),
496        );
497        let connection_rate_keys: Arc<[Ustr]> = Arc::from([websocket_connection_key()]);
498        let client = WebSocketClient::builder()
499            .config(config.clone())
500            .message_handler(raw_handler.clone())
501            .rate_limiter(Arc::clone(&message_rate_limiter))
502            .connection_rate_limiter(Arc::clone(&connection_rate_limiter))
503            .connection_rate_keys(Arc::clone(&connection_rate_keys))
504            .initial_connect_retry_policy(Self::initial_connect_retry_policy())
505            .cancellation_token(cancellation_token)
506            .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
507            .connect()
508            .await
509            .map_err(|e| {
510                BybitWsError::Transport(format!(
511                    "WebSocket connection failed: {}: {}",
512                    self.url.origin(),
513                    e.redacted_message()
514                ))
515            })?;
516
517        let connection_mode = client.connection_mode_atomic();
518        let reconnect_handle = client.reconnect_handle();
519        client.set_auth_tracker(self.auth_tracker.clone(), self.requires_auth);
520
521        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<BybitWsMessage>();
522        self.out_rx = Some(Arc::new(out_rx));
523
524        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
525
526        // Queue the client first so no subscribe reaches the handler before it
527        cmd_tx
528            .send(HandlerCommand::SetClient(client))
529            .map_err(|e| BybitWsError::Send(e.to_string()))?;
530
531        {
532            // Commands sent before this swap went to a closed channel and close skips
533            // unsubscribes, so the new session starts from clean subscription state.
534            let _guard = self.subscription_guard.lock().await;
535            let mut command_sender = self.cmd_tx.write().await;
536            self.subscriptions.clear();
537            *command_sender = cmd_tx.clone();
538            self.connection_mode.store(connection_mode);
539        }
540
541        let signal = Arc::clone(&self.signal);
542        let subscriptions = self.subscriptions.clone();
543        let credential = self.credential.clone();
544        let requires_auth = self.requires_auth;
545        let cmd_tx_for_reconnect = cmd_tx.clone();
546        let auth_tracker = self.auth_tracker.clone();
547        let auth_tracker_for_handler = auth_tracker.clone();
548        let rate_limiter = self.rate_limiter.clone();
549        let recv_window_ms = Arc::clone(&self.recv_window_ms);
550        let mut rollback = ConnectRollback::new(self);
551
552        if let Err(e) = self.task_handle.spawn(async move {
553            let mut handler = BybitWsFeedHandler::new(
554                signal.clone(),
555                cmd_rx,
556                raw_rx,
557                auth_tracker_for_handler,
558                subscriptions.clone(),
559                rate_limiter,
560                recv_window_ms,
561            );
562
563            // Resubscribe all tracked subscriptions after reconnection
564            let resubscribe_all = || async {
565                let topics = subscriptions.all_topics();
566
567                if topics.is_empty() {
568                    return;
569                }
570
571                log::debug!(
572                    "Resubscribing to confirmed subscriptions: count={}",
573                    topics.len()
574                );
575
576                for topic in &topics {
577                    subscriptions.mark_subscribe(topic.as_str());
578                }
579
580                let mut payloads = Vec::with_capacity(topics.len());
581                for topic in &topics {
582                    let message = BybitSubscription {
583                        op: BybitWsOperation::Subscribe,
584                        args: vec![topic.clone()],
585                        req_id: Some(topic.clone()),
586                    };
587
588                    if let Ok(payload) = serde_json::to_string(&message) {
589                        payloads.push(payload);
590                    }
591                }
592
593                let cmd = HandlerCommand::Subscribe { topics: payloads };
594
595                if let Err(e) = cmd_tx_for_reconnect.send(cmd) {
596                    log::error!("Failed to send resubscribe command: {e}");
597                }
598            };
599
600            // Run message processing with reconnection handling
601            loop {
602                match handler.next().await {
603                    Some(BybitWsMessage::Reconnected) => {
604                        if signal.load(Ordering::Relaxed) {
605                            continue;
606                        }
607
608                        log::info!("WebSocket reconnected");
609
610                        subscriptions.reset_after_reconnect();
611
612                        if requires_auth {
613                            log::debug!("Re-authenticating after reconnection");
614
615                            if let Some(cred) = &credential {
616                                // Begin auth attempt so succeed() will update state
617                                let _rx = auth_tracker.begin();
618
619                                let expires = jiff::Timestamp::now().as_millisecond()
620                                    + WEBSOCKET_AUTH_WINDOW_MS;
621                                let signature = cred.sign_websocket_auth(expires);
622
623                                let auth_message = Zeroizing::new(BybitAuthRequest {
624                                    op: BybitWsOperation::Auth,
625                                    args: vec![
626                                        Value::String(cred.api_key().to_string()),
627                                        Value::Number(expires.into()),
628                                        Value::String(signature),
629                                    ],
630                                });
631
632                                if let Ok(payload) =
633                                    serde_json::to_string(&*auth_message).map(SecretString::from)
634                                {
635                                    let cmd = HandlerCommand::Authenticate { payload };
636                                    if let Err(e) = cmd_tx_for_reconnect.send(cmd) {
637                                        log::error!(
638                                            "Failed to send reconnection auth command: error={e}"
639                                        );
640                                    }
641                                } else {
642                                    log::error!("Failed to serialize reconnection auth message");
643                                }
644                            }
645                        }
646
647                        // Unauthenticated sessions resubscribe immediately after reconnection,
648                        // authenticated sessions wait for Auth message
649                        if !requires_auth {
650                            log::debug!("No authentication required, resubscribing immediately");
651                            resubscribe_all().await;
652                        }
653
654                        // Forward to out_tx so caller sees the Reconnected message
655                        if out_tx.send(BybitWsMessage::Reconnected).is_err() {
656                            if handler.is_stopped() {
657                                log::debug!("Receiver dropped, stopping");
658                            } else {
659                                log::error!("Receiver dropped, stopping");
660                            }
661                            break;
662                        }
663                    }
664                    Some(BybitWsMessage::Auth(ref auth)) => {
665                        let is_success = auth.success.unwrap_or(false) || auth.ret_code == Some(0);
666                        if is_success {
667                            log::debug!("Authenticated, resubscribing");
668                            resubscribe_all().await;
669                        }
670
671                        if out_tx.send(BybitWsMessage::Auth(auth.clone())).is_err() {
672                            if handler.is_stopped() {
673                                log::debug!("Failed to send message (receiver dropped)");
674                            } else {
675                                log::error!("Failed to send message (receiver dropped)");
676                            }
677                            break;
678                        }
679                    }
680                    Some(msg) => {
681                        if out_tx.send(msg).is_err() {
682                            if handler.is_stopped() {
683                                log::debug!("Failed to send message (receiver dropped)");
684                            } else {
685                                log::error!("Failed to send message (receiver dropped)");
686                            }
687                            break;
688                        }
689                    }
690                    None => {
691                        // Stream ended - check if it's a stop signal
692                        if handler.is_stopped() {
693                            log::debug!("Stop signal received, ending message processing");
694                            break;
695                        }
696                        // Otherwise it's an unexpected stream end
697                        log::warn!("WebSocket stream ended unexpectedly");
698                        break;
699                    }
700                }
701            }
702
703            log::debug!("Handler task exiting");
704        }) {
705            let shutdown_result = self.close_locked().await;
706            return Err(BybitWsError::ClientError(match shutdown_result {
707                Ok(()) => format!("Failed to start WebSocket handler task: {e}"),
708                Err(shutdown_error) => format!(
709                    "Failed to start WebSocket handler task: {e}; startup rollback failed: \
710                     {shutdown_error}"
711                ),
712            }));
713        }
714
715        if let Some(control) = &self.socket_control {
716            control.register(move || reconnect_handle.request_reconnect());
717        }
718
719        if requires_auth && let Err(e) = self.authenticate_if_required().await {
720            let result = match self.close_locked().await {
721                Ok(()) => Err(e),
722                Err(shutdown_error) => Err(BybitWsError::ClientError(format!(
723                    "{e}; startup rollback failed: {shutdown_error}"
724                ))),
725            };
726            rollback.disarm();
727            return result;
728        }
729
730        rollback.disarm();
731        Ok(())
732    }
733
734    /// Disconnects the WebSocket client and stops the background task.
735    pub async fn close(&mut self) -> BybitWsResult<()> {
736        let connect_lock = Arc::clone(&self.connect_lock);
737        let _guard = connect_lock.lock().await;
738        self.close_locked().await
739    }
740
741    async fn close_locked(&self) -> BybitWsResult<()> {
742        log::debug!("Starting close process");
743
744        self.signal.store(true, Ordering::Relaxed);
745        self.cancellation_token.load().cancel();
746
747        let cmd = HandlerCommand::Disconnect;
748        if let Err(e) = self.cmd_tx.read().await.send(cmd) {
749            log::debug!(
750                "Failed to send disconnect command (handler may already be shut down): {e}"
751            );
752        }
753
754        let task_result = if self.task_handle.is_empty() {
755            log::debug!("No task handle to await");
756            Ok(())
757        } else {
758            log::debug!("Waiting for task handle to complete");
759
760            if let Some(outcome) = self
761                .task_handle
762                .finish(Duration::from_secs(2), Duration::from_secs(2))
763                .await
764            {
765                match outcome {
766                    TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => Ok(()),
767                    TaskJoinOutcome::Failed(error) => Err(BybitWsError::ClientError(format!(
768                        "WebSocket handler task failed: {error}"
769                    ))),
770                    TaskJoinOutcome::Incomplete => Err(BybitWsError::ClientError(
771                        "WebSocket handler task did not stop after abort".to_string(),
772                    )),
773                }
774            } else {
775                Ok(())
776            }
777        };
778
779        self.auth_tracker.invalidate();
780
781        if let Some(control) = &self.socket_control {
782            control.deregister();
783        }
784
785        log::debug!("Closed");
786
787        task_result
788    }
789
790    /// Returns a value indicating whether the client is active.
791    #[must_use]
792    pub fn is_active(&self) -> bool {
793        let connection_mode_arc = self.connection_mode.load();
794        ConnectionMode::from_atomic(&connection_mode_arc).is_active()
795            && !self.signal.load(Ordering::Relaxed)
796    }
797
798    /// Returns a value indicating whether the client is closed.
799    pub fn is_closed(&self) -> bool {
800        let connection_mode_arc = self.connection_mode.load();
801        ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
802            || self.signal.load(Ordering::Relaxed)
803    }
804
805    /// Waits until the WebSocket client becomes active or times out.
806    ///
807    /// # Errors
808    ///
809    /// Returns an error if the timeout is exceeded before the client becomes active.
810    pub async fn wait_until_active(&self, timeout_secs: f64) -> BybitWsResult<()> {
811        let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
812
813        tokio::time::timeout(timeout, async {
814            while !self.is_active() {
815                tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
816            }
817        })
818        .await
819        .map_err(|_| {
820            BybitWsError::ClientError(format!(
821                "WebSocket connection timeout after {timeout_secs} seconds"
822            ))
823        })?;
824
825        Ok(())
826    }
827
828    /// Subscribe to the provided topic strings.
829    pub async fn subscribe(&self, topics: Vec<String>) -> BybitWsResult<()> {
830        if topics.is_empty() {
831            return Ok(());
832        }
833        let _guard = self.subscription_guard.lock().await;
834
835        log::debug!("Subscribing to topics: {topics:?}");
836
837        let payloads = self.register_subscribe(topics)?;
838
839        if payloads.is_empty() {
840            return Ok(());
841        }
842
843        let cmd = HandlerCommand::Subscribe { topics: payloads };
844        self.cmd_tx
845            .read()
846            .await
847            .send(cmd)
848            .map_err(|e| BybitWsError::Send(format!("Failed to send subscribe command: {e}")))?;
849
850        Ok(())
851    }
852
853    // Adds a reference to each topic, returning subscribe payloads for topics no other
854    // subscription holds; the caller holds the subscription guard
855    fn register_subscribe(&self, topics: Vec<String>) -> BybitWsResult<Vec<String>> {
856        if self.product_type == Some(BybitProductType::Option) {
857            let occupied_topics = self
858                .subscriptions
859                .all_topics()
860                .into_iter()
861                .chain(self.subscriptions.pending_unsubscribe_topics())
862                .collect::<HashSet<_>>();
863            let new_topics = topics
864                .iter()
865                .filter(|topic| !occupied_topics.contains(topic.as_str()))
866                .collect::<HashSet<_>>()
867                .len();
868            let requested = occupied_topics.len() + new_topics;
869            if requested > BYBIT_OPTION_SUBSCRIPTION_LIMIT {
870                return Err(BybitWsError::ClientError(format!(
871                    "Option WebSocket subscription limit is {BYBIT_OPTION_SUBSCRIPTION_LIMIT} arguments per connection, requested {requested}"
872                )));
873            }
874        }
875
876        // Use reference counting to deduplicate subscriptions
877        let mut topics_to_send = Vec::new();
878
879        for topic in topics {
880            // Returns true if this is the first subscription (ref count 0 -> 1)
881            if self.subscriptions.add_reference(&topic) {
882                self.subscriptions.mark_subscribe(&topic);
883                topics_to_send.push(topic.clone());
884            } else {
885                log::debug!("Already subscribed to {topic}, skipping duplicate subscription");
886            }
887        }
888
889        // Serialize subscription messages
890        let mut payloads = Vec::with_capacity(topics_to_send.len());
891        for topic in &topics_to_send {
892            let message = BybitSubscription {
893                op: BybitWsOperation::Subscribe,
894                args: vec![topic.clone()],
895                req_id: Some(topic.clone()),
896            };
897            let payload = serde_json::to_string(&message).map_err(|e| {
898                BybitWsError::Json(format!("Failed to serialize subscription: {e}"))
899            })?;
900            payloads.push(payload);
901        }
902
903        Ok(payloads)
904    }
905
906    /// Unsubscribe from the provided topics.
907    pub async fn unsubscribe(&self, topics: Vec<String>) -> BybitWsResult<()> {
908        if topics.is_empty() {
909            return Ok(());
910        }
911
912        log::debug!("Attempting to unsubscribe from topics: {topics:?}");
913
914        if self.signal.load(Ordering::Relaxed) {
915            log::debug!("Shutdown signal detected, skipping unsubscribe");
916            return Ok(());
917        }
918        let _guard = self.subscription_guard.lock().await;
919
920        // Use reference counting to avoid unsubscribing while other consumers still need the topic
921        let mut topics_to_send = Vec::new();
922
923        for topic in topics {
924            // Returns true if this was the last subscription (ref count 1 -> 0)
925            if self.subscriptions.remove_reference(&topic) {
926                self.subscriptions.mark_unsubscribe(&topic);
927                topics_to_send.push(topic.clone());
928            } else {
929                log::debug!("Topic {topic} still has active subscriptions, not unsubscribing");
930            }
931        }
932
933        if topics_to_send.is_empty() {
934            return Ok(());
935        }
936
937        // Serialize unsubscription messages
938        let mut payloads = Vec::with_capacity(topics_to_send.len());
939        for topic in &topics_to_send {
940            let message = BybitSubscription {
941                op: BybitWsOperation::Unsubscribe,
942                args: vec![topic.clone()],
943                req_id: Some(topic.clone()),
944            };
945
946            if let Ok(payload) = serde_json::to_string(&message) {
947                payloads.push(payload);
948            }
949        }
950
951        let cmd = HandlerCommand::Unsubscribe { topics: payloads };
952        if let Err(e) = self.cmd_tx.read().await.send(cmd) {
953            log::debug!("Failed to send unsubscribe command: error={e}");
954        }
955
956        Ok(())
957    }
958
959    /// Returns a stream of venue-typed [`BybitWsMessage`] items.
960    ///
961    /// # Panics
962    ///
963    /// Panics if called before [`Self::connect`] or if the stream has already been taken.
964    pub fn stream(&mut self) -> impl futures_util::Stream<Item = BybitWsMessage> + use<> {
965        let rx = self
966            .out_rx
967            .take()
968            .expect("Stream receiver already taken or client not connected");
969        let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
970        async_stream::stream! {
971            while let Some(msg) = rx.recv().await {
972                yield msg;
973            }
974        }
975    }
976
977    /// Returns the number of currently registered subscriptions.
978    #[must_use]
979    pub fn subscription_count(&self) -> usize {
980        self.subscriptions.len()
981    }
982
983    /// Returns the credential associated with this client, if any.
984    #[must_use]
985    pub fn credential(&self) -> Option<&Credential> {
986        self.credential.as_ref()
987    }
988
989    /// Sets the account ID for account message parsing.
990    pub fn set_account_id(&mut self, account_id: AccountId) {
991        self.account_id = Some(account_id);
992    }
993
994    /// Sets the account market maker level.
995    pub fn set_mm_level(&self, mm_level: u8) {
996        self.mm_level.store(mm_level, Ordering::Relaxed);
997    }
998
999    /// Returns the account ID if set.
1000    #[must_use]
1001    pub fn account_id(&self) -> Option<AccountId> {
1002        self.account_id
1003    }
1004
1005    /// Returns the product type for public connections.
1006    #[must_use]
1007    pub fn product_type(&self) -> Option<BybitProductType> {
1008        self.product_type
1009    }
1010
1011    /// Returns a reference to the bar types cache.
1012    #[must_use]
1013    pub fn bar_types_cache(&self) -> &Arc<AtomicMap<String, BarType>> {
1014        &self.bar_types_cache
1015    }
1016
1017    /// Adds an instrument to the shared instruments cache.
1018    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1019        self.instruments_cache
1020            .insert(instrument.id().symbol.inner(), instrument);
1021    }
1022
1023    /// Returns a snapshot of the instruments cache keyed by symbol.
1024    #[must_use]
1025    pub fn instruments_snapshot(&self) -> ahash::AHashMap<Ustr, InstrumentAny> {
1026        (**self.instruments_cache.load()).clone()
1027    }
1028
1029    /// Adds an instrument ID to the option greeks subscription set.
1030    pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1031        self.option_greeks_subs.insert(instrument_id);
1032    }
1033
1034    /// Removes an instrument ID from the option greeks subscription set.
1035    pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1036        self.option_greeks_subs.remove(instrument_id);
1037    }
1038
1039    /// Returns a reference to the option greeks subscription set.
1040    #[must_use]
1041    pub fn option_greeks_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
1042        &self.option_greeks_subs
1043    }
1044
1045    /// Returns a reference to the trade subscriptions set.
1046    #[must_use]
1047    pub fn trade_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
1048        &self.trade_subs
1049    }
1050
1051    /// Returns a reference to the live instruments cache Arc.
1052    #[must_use]
1053    pub fn instruments_cache_ref(&self) -> &Arc<AtomicMap<Ustr, InstrumentAny>> {
1054        &self.instruments_cache
1055    }
1056
1057    /// Subscribes to orderbook updates for a specific instrument.
1058    ///
1059    /// # Errors
1060    ///
1061    /// Returns an error if the subscription request fails.
1062    ///
1063    /// # References
1064    ///
1065    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>
1066    pub async fn subscribe_orderbook(
1067        &self,
1068        instrument_id: InstrumentId,
1069        depth: u32,
1070    ) -> BybitWsResult<()> {
1071        let topic = orderbook_topic(instrument_id, depth);
1072        self.subscribe(vec![topic]).await
1073    }
1074
1075    /// Subscribes to an order book topic, opening `gate` once the connection confirms the
1076    /// subscribe write.
1077    ///
1078    /// A topic another subscription already holds, such as the depth-1 topic quotes use, sends
1079    /// nothing and opens `gate` at once. A failed write returns [`BybitWsError::Transport`]; any
1080    /// other error means the request never reached the connection.
1081    pub(crate) async fn subscribe_book(
1082        &self,
1083        instrument_id: InstrumentId,
1084        depth: u32,
1085        cancel: CancellationToken,
1086        gate: SnapshotGate,
1087    ) -> BybitWsResult<()> {
1088        let (completion, receiver) = tokio::sync::oneshot::channel();
1089
1090        {
1091            let _guard = self.subscription_guard.lock().await;
1092            let topic = orderbook_topic(instrument_id, depth);
1093
1094            let Some(payload) = self.register_subscribe(vec![topic])?.pop() else {
1095                gate.open();
1096                return Ok(());
1097            };
1098
1099            let cmd = HandlerCommand::SubscribeBook {
1100                payload,
1101                gate,
1102                completion,
1103            };
1104
1105            self.cmd_tx.read().await.send(cmd).map_err(|e| {
1106                BybitWsError::Send(format!("Failed to send subscribe command: {e}"))
1107            })?;
1108        }
1109
1110        tokio::select! {
1111            biased;
1112            () = cancel.cancelled() => Ok(()),
1113            result = receiver => result.map_err(|e| BybitWsError::Send(e.to_string()))?,
1114        }
1115    }
1116
1117    /// Requests a fresh snapshot by replacing an order book subscription on the current
1118    /// connection.
1119    pub(crate) async fn resubscribe_book(
1120        &self,
1121        instrument_id: InstrumentId,
1122        depth: u32,
1123        cancel: CancellationToken,
1124        gate: SnapshotGate,
1125    ) -> BybitWsResult<()> {
1126        let topic = orderbook_topic(instrument_id, depth);
1127        let (completion, receiver) = tokio::sync::oneshot::channel();
1128        let sender = self.cmd_tx.read().await;
1129
1130        if cancel.is_cancelled() {
1131            return Err(BybitWsError::ClientError(
1132                "Book recovery canceled".to_string(),
1133            ));
1134        }
1135
1136        let cmd = HandlerCommand::ResubscribeBook {
1137            topic,
1138            cancel,
1139            gate,
1140            completion,
1141        };
1142
1143        sender
1144            .send(cmd)
1145            .map_err(|e| BybitWsError::Send(format!("Failed to send resubscribe command: {e}")))?;
1146        drop(sender);
1147
1148        receiver
1149            .await
1150            .map_err(|e| BybitWsError::Send(e.to_string()))?
1151    }
1152
1153    /// Unsubscribes from orderbook updates for a specific instrument.
1154    pub async fn unsubscribe_orderbook(
1155        &self,
1156        instrument_id: InstrumentId,
1157        depth: u32,
1158    ) -> BybitWsResult<()> {
1159        let topic = orderbook_topic(instrument_id, depth);
1160        self.unsubscribe(vec![topic]).await
1161    }
1162
1163    /// Subscribes to public trade updates for a specific instrument.
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns an error if the subscription request fails.
1168    ///
1169    /// # References
1170    ///
1171    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/trade>
1172    pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1173        self.trade_subs.insert(instrument_id);
1174        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1175        // Bybit option trades use baseCoin topic (e.g. publicTrade.BTC)
1176        let topic_symbol = match self.product_type {
1177            Some(BybitProductType::Option) => extract_base_coin(raw_symbol),
1178            _ => raw_symbol,
1179        };
1180        let topic = format!(
1181            "{}.{topic_symbol}",
1182            BybitWsPublicChannel::PublicTrade.as_ref()
1183        );
1184        self.subscribe(vec![topic]).await
1185    }
1186
1187    /// Unsubscribes from public trade updates for a specific instrument.
1188    pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1189        self.trade_subs.remove(&instrument_id);
1190        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1191        let topic_symbol = match self.product_type {
1192            Some(BybitProductType::Option) => extract_base_coin(raw_symbol),
1193            _ => raw_symbol,
1194        };
1195        let topic = format!(
1196            "{}.{topic_symbol}",
1197            BybitWsPublicChannel::PublicTrade.as_ref()
1198        );
1199        self.unsubscribe(vec![topic]).await
1200    }
1201
1202    /// Subscribes to public liquidation updates for a specific instrument.
1203    ///
1204    /// Bybit publishes this stream for linear and inverse contracts only.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns an error if the subscription request fails.
1209    ///
1210    /// # References
1211    ///
1212    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/all-liquidation>
1213    pub async fn subscribe_liquidations(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1214        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1215        let topic = format!(
1216            "{}.{raw_symbol}",
1217            BybitWsPublicChannel::AllLiquidation.as_ref()
1218        );
1219        self.subscribe(vec![topic]).await
1220    }
1221
1222    /// Unsubscribes from public liquidation updates for a specific instrument.
1223    pub async fn unsubscribe_liquidations(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1224        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1225        let topic = format!(
1226            "{}.{raw_symbol}",
1227            BybitWsPublicChannel::AllLiquidation.as_ref()
1228        );
1229        self.unsubscribe(vec![topic]).await
1230    }
1231
1232    /// Subscribes to ticker updates for a specific instrument.
1233    ///
1234    /// # Errors
1235    ///
1236    /// Returns an error if the subscription request fails.
1237    ///
1238    /// # References
1239    ///
1240    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/ticker>
1241    pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1242        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1243        let topic = format!("{}.{raw_symbol}", BybitWsPublicChannel::Tickers.as_ref());
1244        self.subscribe(vec![topic]).await
1245    }
1246
1247    /// Unsubscribes from ticker updates for a specific instrument.
1248    pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1249        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1250        let topic = format!("{}.{raw_symbol}", BybitWsPublicChannel::Tickers.as_ref());
1251        self.unsubscribe(vec![topic]).await
1252    }
1253
1254    /// Subscribes to kline/candlestick updates for a specific instrument.
1255    ///
1256    /// # Errors
1257    ///
1258    /// Returns an error if the subscription request fails.
1259    ///
1260    /// # References
1261    ///
1262    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/kline>
1263    pub async fn subscribe_bars(&self, bar_type: BarType) -> BybitWsResult<()> {
1264        if self.product_type == Some(BybitProductType::Option) {
1265            return Err(BybitWsError::ClientError(
1266                "Bybit does not support kline/bar data for options".to_string(),
1267            ));
1268        }
1269
1270        let spec = bar_type.spec();
1271
1272        if spec.price_type != PriceType::Last {
1273            return Err(BybitWsError::ClientError(format!(
1274                "Invalid bar type: Bybit bars only support LAST price type, received {}",
1275                spec.price_type
1276            )));
1277        }
1278
1279        if bar_type.aggregation_source() != AggregationSource::External {
1280            return Err(BybitWsError::ClientError(format!(
1281                "Invalid bar type: Bybit bars only support EXTERNAL aggregation source, received {}",
1282                bar_type.aggregation_source()
1283            )));
1284        }
1285
1286        let interval = bar_spec_to_bybit_interval(spec.aggregation, spec.step.get() as u64)
1287            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1288
1289        let instrument_id = bar_type.instrument_id();
1290        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1291        let topic = format!(
1292            "{}.{}.{raw_symbol}",
1293            BybitWsPublicChannel::Kline.as_ref(),
1294            interval
1295        );
1296
1297        // Coordinate with reference counting to avoid duplicate cache entries
1298        if self.subscriptions.get_reference_count(&topic) == 0 {
1299            self.bar_types_cache.insert(topic.clone(), bar_type);
1300        }
1301
1302        self.subscribe(vec![topic]).await
1303    }
1304
1305    /// Unsubscribes from kline/candlestick updates for a specific instrument.
1306    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> BybitWsResult<()> {
1307        let spec = bar_type.spec();
1308        let interval = bar_spec_to_bybit_interval(spec.aggregation, spec.step.get() as u64)
1309            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1310
1311        let instrument_id = bar_type.instrument_id();
1312        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1313        let topic = format!(
1314            "{}.{}.{raw_symbol}",
1315            BybitWsPublicChannel::Kline.as_ref(),
1316            interval
1317        );
1318
1319        // Coordinate with reference counting to preserve cache for other subscribers
1320        if self.subscriptions.get_reference_count(&topic) == 1 {
1321            self.bar_types_cache.remove(&topic);
1322        }
1323
1324        self.unsubscribe(vec![topic]).await
1325    }
1326
1327    /// Subscribes to order updates.
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns an error if the subscription request fails or if not authenticated.
1332    ///
1333    /// # References
1334    ///
1335    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/order>
1336    pub async fn subscribe_orders(&self) -> BybitWsResult<()> {
1337        if !self.requires_auth {
1338            return Err(BybitWsError::Authentication(
1339                "Order subscription requires authentication".to_string(),
1340            ));
1341        }
1342        self.subscribe(vec![BybitWsPrivateChannel::Order.as_ref().to_string()])
1343            .await
1344    }
1345
1346    /// Unsubscribes from order updates.
1347    pub async fn unsubscribe_orders(&self) -> BybitWsResult<()> {
1348        self.unsubscribe(vec![BybitWsPrivateChannel::Order.as_ref().to_string()])
1349            .await
1350    }
1351
1352    /// Subscribes to execution/fill updates.
1353    ///
1354    /// # Errors
1355    ///
1356    /// Returns an error if the subscription request fails or if not authenticated.
1357    ///
1358    /// # References
1359    ///
1360    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/execution>
1361    pub async fn subscribe_executions(&self) -> BybitWsResult<()> {
1362        if !self.requires_auth {
1363            return Err(BybitWsError::Authentication(
1364                "Execution subscription requires authentication".to_string(),
1365            ));
1366        }
1367        self.subscribe(vec![BybitWsPrivateChannel::Execution.as_ref().to_string()])
1368            .await
1369    }
1370
1371    /// Unsubscribes from execution/fill updates.
1372    pub async fn unsubscribe_executions(&self) -> BybitWsResult<()> {
1373        self.unsubscribe(vec![BybitWsPrivateChannel::Execution.as_ref().to_string()])
1374            .await
1375    }
1376
1377    /// Subscribes to fast execution updates (slim payload, lower latency).
1378    ///
1379    /// # Errors
1380    ///
1381    /// Returns an error if the subscription request fails or if not authenticated.
1382    ///
1383    /// # References
1384    ///
1385    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/fast-execution>
1386    pub async fn subscribe_executions_fast(&self) -> BybitWsResult<()> {
1387        if !self.requires_auth {
1388            return Err(BybitWsError::Authentication(
1389                "Fast execution subscription requires authentication".to_string(),
1390            ));
1391        }
1392        self.subscribe(vec![
1393            BybitWsPrivateChannel::ExecutionFast.as_ref().to_string(),
1394        ])
1395        .await
1396    }
1397
1398    /// Unsubscribes from fast execution updates.
1399    pub async fn unsubscribe_executions_fast(&self) -> BybitWsResult<()> {
1400        self.unsubscribe(vec![
1401            BybitWsPrivateChannel::ExecutionFast.as_ref().to_string(),
1402        ])
1403        .await
1404    }
1405
1406    /// Subscribes to position updates.
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns an error if the subscription request fails or if not authenticated.
1411    ///
1412    /// # References
1413    ///
1414    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/position>
1415    pub async fn subscribe_positions(&self) -> BybitWsResult<()> {
1416        if !self.requires_auth {
1417            return Err(BybitWsError::Authentication(
1418                "Position subscription requires authentication".to_string(),
1419            ));
1420        }
1421        self.subscribe(vec![BybitWsPrivateChannel::Position.as_ref().to_string()])
1422            .await
1423    }
1424
1425    /// Unsubscribes from position updates.
1426    pub async fn unsubscribe_positions(&self) -> BybitWsResult<()> {
1427        self.unsubscribe(vec![BybitWsPrivateChannel::Position.as_ref().to_string()])
1428            .await
1429    }
1430
1431    /// Subscribes to wallet/balance updates.
1432    ///
1433    /// # Errors
1434    ///
1435    /// Returns an error if the subscription request fails or if not authenticated.
1436    ///
1437    /// # References
1438    ///
1439    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/wallet>
1440    pub async fn subscribe_wallet(&self) -> BybitWsResult<()> {
1441        if !self.requires_auth {
1442            return Err(BybitWsError::Authentication(
1443                "Wallet subscription requires authentication".to_string(),
1444            ));
1445        }
1446        self.subscribe(vec![BybitWsPrivateChannel::Wallet.as_ref().to_string()])
1447            .await
1448    }
1449
1450    /// Unsubscribes from wallet/balance updates.
1451    pub async fn unsubscribe_wallet(&self) -> BybitWsResult<()> {
1452        self.unsubscribe(vec![BybitWsPrivateChannel::Wallet.as_ref().to_string()])
1453            .await
1454    }
1455
1456    /// Waits for the session to be authenticated, aborting early if the client
1457    /// enters a terminal state (closed or disconnecting) during the wait.
1458    async fn require_authenticated(&self) -> BybitWsResult<()> {
1459        if self.is_closed() {
1460            return Err(BybitWsError::ClientError(
1461                "WebSocket client is closed".to_string(),
1462            ));
1463        }
1464
1465        if self.auth_tracker.is_authenticated() {
1466            return Ok(());
1467        }
1468
1469        tokio::select! {
1470            authenticated = self.auth_tracker.wait_for_authenticated(self.auth_wait_timeout) => {
1471                if authenticated {
1472                    Ok(())
1473                } else {
1474                    Err(BybitWsError::Authentication(
1475                        "Must be authenticated".to_string(),
1476                    ))
1477                }
1478            }
1479            () = async {
1480                loop {
1481                    tokio::time::sleep(Duration::from_millis(100)).await;
1482
1483                    if self.is_closed() {
1484                        return;
1485                    }
1486                }
1487            } => {
1488                Err(BybitWsError::ClientError(
1489                    "WebSocket client closed during authentication wait".to_string(),
1490                ))
1491            }
1492        }
1493    }
1494
1495    /// Allocates correlation IDs for product-specific batch chunks.
1496    #[must_use]
1497    pub(crate) fn batch_request_ids(category: BybitProductType, order_count: usize) -> Vec<String> {
1498        let request_count = order_count.div_ceil(batch_send_limit(category));
1499        (0..request_count)
1500            .map(|_| UUID4::new().to_string())
1501            .collect()
1502    }
1503
1504    fn batch_category(
1505        mut categories: impl Iterator<Item = BybitProductType>,
1506    ) -> BybitWsResult<BybitProductType> {
1507        let category = categories.next().ok_or_else(|| {
1508            BybitWsError::ClientError("Batch order request cannot be empty".to_string())
1509        })?;
1510
1511        if categories.any(|candidate| candidate != category) {
1512            return Err(BybitWsError::ClientError(
1513                "Batch order request cannot mix product categories".to_string(),
1514            ));
1515        }
1516        Ok(category)
1517    }
1518
1519    /// Places an order via WebSocket, returning the request ID for correlation.
1520    ///
1521    /// # Errors
1522    ///
1523    /// Returns an error if the order request fails or if not authenticated.
1524    pub async fn place_order(&self, params: BybitWsPlaceOrderParams) -> BybitWsResult<String> {
1525        let req_id = UUID4::new().to_string();
1526        self.place_order_with_id(params, req_id.clone()).await?;
1527        Ok(req_id)
1528    }
1529
1530    pub(crate) async fn place_order_with_id(
1531        &self,
1532        params: BybitWsPlaceOrderParams,
1533        req_id: String,
1534    ) -> BybitWsResult<()> {
1535        self.require_authenticated().await?;
1536        let category = params.category;
1537
1538        let referer = if self.include_referer_header(params.time_in_force) {
1539            Some(BYBIT_NAUTILUS_BROKER_ID.to_string())
1540        } else {
1541            None
1542        };
1543
1544        let command = BybitWsOrderCommand {
1545            req_id,
1546            op: BybitWsOrderRequestOp::Create,
1547            category,
1548            weight: 1,
1549            referer,
1550            args: vec![serde_json::to_value(params)?],
1551        };
1552        self.send_cmd(HandlerCommand::SendOrder { command }).await
1553    }
1554
1555    /// Amends an existing order via WebSocket, returning the request ID for correlation.
1556    ///
1557    /// # Errors
1558    ///
1559    /// Returns an error if the amend request fails or if not authenticated.
1560    pub async fn amend_order(&self, params: BybitWsAmendOrderParams) -> BybitWsResult<String> {
1561        let req_id = UUID4::new().to_string();
1562        self.amend_order_with_id(params, req_id.clone()).await?;
1563        Ok(req_id)
1564    }
1565
1566    pub(crate) async fn amend_order_with_id(
1567        &self,
1568        params: BybitWsAmendOrderParams,
1569        req_id: String,
1570    ) -> BybitWsResult<()> {
1571        self.require_authenticated().await?;
1572        let command = BybitWsOrderCommand {
1573            category: params.category,
1574            req_id,
1575            op: BybitWsOrderRequestOp::Amend,
1576            weight: 1,
1577            referer: None,
1578            args: vec![serde_json::to_value(params)?],
1579        };
1580        self.send_cmd(HandlerCommand::SendOrder { command }).await
1581    }
1582
1583    /// Cancels an order via WebSocket, returning the request ID for correlation.
1584    ///
1585    /// # Errors
1586    ///
1587    /// Returns an error if the cancel request fails or if not authenticated.
1588    pub async fn cancel_order(&self, params: BybitWsCancelOrderParams) -> BybitWsResult<String> {
1589        let req_id = UUID4::new().to_string();
1590        self.cancel_order_with_id(params, req_id.clone()).await?;
1591        Ok(req_id)
1592    }
1593
1594    pub(crate) async fn cancel_order_with_id(
1595        &self,
1596        params: BybitWsCancelOrderParams,
1597        req_id: String,
1598    ) -> BybitWsResult<()> {
1599        self.require_authenticated().await?;
1600        let command = BybitWsOrderCommand {
1601            category: params.category,
1602            req_id,
1603            op: BybitWsOrderRequestOp::Cancel,
1604            weight: 1,
1605            referer: None,
1606            args: vec![serde_json::to_value(params)?],
1607        };
1608        self.send_cmd(HandlerCommand::SendOrder { command }).await
1609    }
1610
1611    /// Batch creates multiple orders via WebSocket, returning the request ID for correlation.
1612    ///
1613    /// # Errors
1614    ///
1615    /// Returns an error if the batch request fails or if not authenticated.
1616    pub async fn batch_place_orders(
1617        &self,
1618        orders: Vec<BybitWsPlaceOrderParams>,
1619    ) -> BybitWsResult<Vec<String>> {
1620        self.require_authenticated().await?;
1621
1622        if orders.is_empty() {
1623            log::warn!("Batch place orders called with empty orders list");
1624            return Ok(vec![]);
1625        }
1626
1627        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1628        let req_ids = Self::batch_request_ids(category, orders.len());
1629        self.batch_place_orders_with_ids(orders, req_ids.clone())
1630            .await?;
1631        Ok(req_ids)
1632    }
1633
1634    pub(crate) async fn batch_place_orders_with_ids(
1635        &self,
1636        orders: Vec<BybitWsPlaceOrderParams>,
1637        req_ids: Vec<String>,
1638    ) -> BybitWsResult<()> {
1639        self.require_authenticated().await?;
1640        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1641        let chunk_limit = batch_send_limit(category);
1642        if req_ids.len() != orders.len().div_ceil(chunk_limit) {
1643            return Err(BybitWsError::ClientError(
1644                "Batch request ID count does not match order chunks".to_string(),
1645            ));
1646        }
1647
1648        let mut commands = Vec::with_capacity(req_ids.len());
1649        for (orders, req_id) in orders.chunks(chunk_limit).zip(req_ids) {
1650            commands.push(self.build_batch_place_command(orders.to_vec(), req_id)?);
1651        }
1652        self.send_cmd(HandlerCommand::SendOrders { commands }).await
1653    }
1654
1655    fn build_batch_place_command(
1656        &self,
1657        orders: Vec<BybitWsPlaceOrderParams>,
1658        req_id: String,
1659    ) -> BybitWsResult<BybitWsOrderCommand> {
1660        let category = orders[0].category;
1661        let order_count = orders.len();
1662
1663        let mm_level = self.mm_level.load(Ordering::Relaxed);
1664        let has_non_post_only = orders
1665            .iter()
1666            .any(|o| !matches!(o.time_in_force, Some(BybitTimeInForce::PostOnly)));
1667        let referer = if has_non_post_only || mm_level == 0 {
1668            Some(BYBIT_NAUTILUS_BROKER_ID.to_string())
1669        } else {
1670            None
1671        };
1672
1673        let request_items: Vec<BybitWsBatchPlaceItem> = orders
1674            .into_iter()
1675            .map(|order| BybitWsBatchPlaceItem {
1676                symbol: order.symbol,
1677                side: order.side,
1678                order_type: order.order_type,
1679                qty: order.qty,
1680                is_leverage: order.is_leverage,
1681                market_unit: order.market_unit,
1682                price: order.price,
1683                time_in_force: order.time_in_force,
1684                order_link_id: order.order_link_id,
1685                reduce_only: order.reduce_only,
1686                close_on_trigger: order.close_on_trigger,
1687                trigger_price: order.trigger_price,
1688                trigger_by: order.trigger_by,
1689                trigger_direction: order.trigger_direction,
1690                tpsl_mode: order.tpsl_mode,
1691                take_profit: order.take_profit,
1692                stop_loss: order.stop_loss,
1693                tp_trigger_by: order.tp_trigger_by,
1694                sl_trigger_by: order.sl_trigger_by,
1695                sl_trigger_price: order.sl_trigger_price,
1696                tp_trigger_price: order.tp_trigger_price,
1697                sl_order_type: order.sl_order_type,
1698                tp_order_type: order.tp_order_type,
1699                sl_limit_price: order.sl_limit_price,
1700                tp_limit_price: order.tp_limit_price,
1701                order_iv: order.order_iv,
1702                smp_type: order.smp_type,
1703                mmp: order.mmp,
1704                position_idx: order.position_idx,
1705                bbo_side_type: order.bbo_side_type,
1706                bbo_level: order.bbo_level,
1707            })
1708            .collect();
1709
1710        let args = BybitWsBatchPlaceOrderArgs {
1711            category,
1712            request: request_items,
1713        };
1714
1715        Ok(BybitWsOrderCommand {
1716            req_id,
1717            op: BybitWsOrderRequestOp::CreateBatch,
1718            category,
1719            weight: batch_weight(category, order_count),
1720            referer,
1721            args: vec![serde_json::to_value(args)?],
1722        })
1723    }
1724
1725    /// Batch amends multiple orders via WebSocket.
1726    ///
1727    /// # Errors
1728    ///
1729    /// Returns an error if the batch request fails or if not authenticated.
1730    pub async fn batch_amend_orders(
1731        &self,
1732        orders: Vec<BybitWsAmendOrderParams>,
1733    ) -> BybitWsResult<Vec<String>> {
1734        self.require_authenticated().await?;
1735
1736        if orders.is_empty() {
1737            log::warn!("Batch amend orders called with empty orders list");
1738            return Ok(vec![]);
1739        }
1740
1741        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1742        let req_ids = Self::batch_request_ids(category, orders.len());
1743        self.batch_amend_orders_with_ids(orders, req_ids.clone())
1744            .await?;
1745        Ok(req_ids)
1746    }
1747
1748    pub(crate) async fn batch_amend_orders_with_ids(
1749        &self,
1750        orders: Vec<BybitWsAmendOrderParams>,
1751        req_ids: Vec<String>,
1752    ) -> BybitWsResult<()> {
1753        self.require_authenticated().await?;
1754        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1755        let chunk_limit = batch_send_limit(category);
1756        if req_ids.len() != orders.len().div_ceil(chunk_limit) {
1757            return Err(BybitWsError::ClientError(
1758                "Batch request ID count does not match order chunks".to_string(),
1759            ));
1760        }
1761
1762        let mut commands = Vec::with_capacity(req_ids.len());
1763        for (orders, req_id) in orders.chunks(chunk_limit).zip(req_ids) {
1764            commands.push(Self::build_batch_amend_command(orders.to_vec(), req_id)?);
1765        }
1766        self.send_cmd(HandlerCommand::SendOrders { commands }).await
1767    }
1768
1769    fn build_batch_amend_command(
1770        orders: Vec<BybitWsAmendOrderParams>,
1771        req_id: String,
1772    ) -> BybitWsResult<BybitWsOrderCommand> {
1773        let category = orders[0].category;
1774        let order_count = orders.len();
1775
1776        let request_items = orders
1777            .into_iter()
1778            .map(|order| BybitWsBatchAmendItem {
1779                symbol: order.symbol,
1780                order_id: order.order_id,
1781                order_link_id: order.order_link_id,
1782                qty: order.qty,
1783                price: order.price,
1784                trigger_price: order.trigger_price,
1785                take_profit: order.take_profit,
1786                stop_loss: order.stop_loss,
1787                tp_trigger_by: order.tp_trigger_by,
1788                sl_trigger_by: order.sl_trigger_by,
1789                order_iv: order.order_iv,
1790            })
1791            .collect();
1792
1793        let args = BybitWsBatchAmendOrderArgs {
1794            category,
1795            request: request_items,
1796        };
1797
1798        Ok(BybitWsOrderCommand {
1799            req_id,
1800            op: BybitWsOrderRequestOp::AmendBatch,
1801            category,
1802            weight: batch_weight(category, order_count),
1803            referer: None,
1804            args: vec![serde_json::to_value(args)?],
1805        })
1806    }
1807
1808    /// Batch cancels multiple orders via WebSocket, returning the request ID for correlation.
1809    ///
1810    /// # Errors
1811    ///
1812    /// Returns an error if the batch request fails or if not authenticated.
1813    pub async fn batch_cancel_orders(
1814        &self,
1815        orders: Vec<BybitWsCancelOrderParams>,
1816    ) -> BybitWsResult<Vec<String>> {
1817        self.require_authenticated().await?;
1818
1819        if orders.is_empty() {
1820            log::warn!("Batch cancel orders called with empty orders list");
1821            return Ok(vec![]);
1822        }
1823
1824        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1825        let req_ids = Self::batch_request_ids(category, orders.len());
1826        self.batch_cancel_orders_with_ids(orders, req_ids.clone())
1827            .await?;
1828        Ok(req_ids)
1829    }
1830
1831    pub(crate) async fn batch_cancel_orders_with_ids(
1832        &self,
1833        orders: Vec<BybitWsCancelOrderParams>,
1834        req_ids: Vec<String>,
1835    ) -> BybitWsResult<()> {
1836        self.require_authenticated().await?;
1837
1838        if orders.is_empty() {
1839            return Ok(());
1840        }
1841
1842        let category = Self::batch_category(orders.iter().map(|order| order.category))?;
1843        let chunk_limit = batch_send_limit(category);
1844        if req_ids.len() != orders.len().div_ceil(chunk_limit) {
1845            return Err(BybitWsError::ClientError(
1846                "Batch request ID count does not match order chunks".to_string(),
1847            ));
1848        }
1849
1850        let mut commands = Vec::with_capacity(req_ids.len());
1851        for (orders, req_id) in orders.chunks(chunk_limit).zip(req_ids) {
1852            commands.push(Self::build_batch_cancel_command(orders.to_vec(), req_id)?);
1853        }
1854        self.send_cmd(HandlerCommand::SendOrders { commands }).await
1855    }
1856
1857    fn build_batch_cancel_command(
1858        orders: Vec<BybitWsCancelOrderParams>,
1859        req_id: String,
1860    ) -> BybitWsResult<BybitWsOrderCommand> {
1861        let category = orders[0].category;
1862        let order_count = orders.len();
1863
1864        let request_items: Vec<BybitWsBatchCancelItem> = orders
1865            .into_iter()
1866            .map(|order| BybitWsBatchCancelItem {
1867                symbol: order.symbol,
1868                order_id: order.order_id,
1869                order_link_id: order.order_link_id,
1870            })
1871            .collect();
1872
1873        let args = BybitWsBatchCancelOrderArgs {
1874            category,
1875            request: request_items,
1876        };
1877
1878        Ok(BybitWsOrderCommand {
1879            req_id,
1880            op: BybitWsOrderRequestOp::CancelBatch,
1881            category,
1882            weight: batch_weight(category, order_count),
1883            referer: None,
1884            args: vec![serde_json::to_value(args)?],
1885        })
1886    }
1887
1888    /// Submits an order using Nautilus domain objects.
1889    ///
1890    /// # Errors
1891    ///
1892    /// Returns an error if order submission fails or if not authenticated.
1893    #[expect(clippy::too_many_arguments)]
1894    pub async fn submit_order(
1895        &self,
1896        product_type: BybitProductType,
1897        instrument_id: InstrumentId,
1898        client_order_id: ClientOrderId,
1899        order_side: OrderSide,
1900        order_type: OrderType,
1901        quantity: Quantity,
1902        is_quote_quantity: bool,
1903        time_in_force: Option<TimeInForce>,
1904        price: Option<Price>,
1905        trigger_price: Option<Price>,
1906        trigger_type: Option<TriggerType>,
1907        post_only: Option<bool>,
1908        reduce_only: Option<bool>,
1909        is_leverage: bool,
1910        position_idx: Option<BybitPositionIdx>,
1911        bbo_side_type: Option<BybitBboSideType>,
1912        bbo_level: Option<String>,
1913        smp_type: Option<BybitOrderSmpType>,
1914    ) -> BybitWsResult<String> {
1915        let params = self.build_place_order_params(
1916            product_type,
1917            instrument_id,
1918            client_order_id,
1919            order_side,
1920            order_type,
1921            quantity,
1922            is_quote_quantity,
1923            time_in_force,
1924            price,
1925            trigger_price,
1926            trigger_type,
1927            post_only,
1928            reduce_only,
1929            is_leverage,
1930            None,
1931            None,
1932            position_idx,
1933            bbo_side_type,
1934            bbo_level,
1935            smp_type,
1936        )?;
1937
1938        self.place_order(params).await
1939    }
1940
1941    /// Modifies an existing order using Nautilus domain objects.
1942    ///
1943    /// # Errors
1944    ///
1945    /// Returns an error if modification fails or if not authenticated.
1946    pub async fn modify_order(
1947        &self,
1948        product_type: BybitProductType,
1949        instrument_id: InstrumentId,
1950        client_order_id: ClientOrderId,
1951        venue_order_id: Option<VenueOrderId>,
1952        quantity: Option<Quantity>,
1953        price: Option<Price>,
1954    ) -> BybitWsResult<String> {
1955        let params = self.build_amend_order_params(
1956            product_type,
1957            instrument_id,
1958            venue_order_id,
1959            Some(client_order_id),
1960            quantity,
1961            price,
1962        )?;
1963
1964        self.amend_order(params).await
1965    }
1966
1967    /// Cancels an order using Nautilus domain objects.
1968    ///
1969    /// # Errors
1970    ///
1971    /// Returns an error if cancellation fails or if not authenticated.
1972    pub async fn cancel_order_by_id(
1973        &self,
1974        product_type: BybitProductType,
1975        instrument_id: InstrumentId,
1976        client_order_id: ClientOrderId,
1977        venue_order_id: Option<VenueOrderId>,
1978    ) -> BybitWsResult<String> {
1979        let params = self.build_cancel_order_params(
1980            product_type,
1981            instrument_id,
1982            venue_order_id,
1983            Some(client_order_id),
1984        )?;
1985
1986        self.cancel_order(params).await
1987    }
1988
1989    /// Builds order params for placing an order.
1990    #[expect(clippy::too_many_arguments)]
1991    pub fn build_place_order_params(
1992        &self,
1993        product_type: BybitProductType,
1994        instrument_id: InstrumentId,
1995        client_order_id: ClientOrderId,
1996        order_side: OrderSide,
1997        order_type: OrderType,
1998        quantity: Quantity,
1999        is_quote_quantity: bool,
2000        time_in_force: Option<TimeInForce>,
2001        price: Option<Price>,
2002        trigger_price: Option<Price>,
2003        trigger_type: Option<TriggerType>,
2004        post_only: Option<bool>,
2005        reduce_only: Option<bool>,
2006        is_leverage: bool,
2007        take_profit: Option<Price>,
2008        stop_loss: Option<Price>,
2009        position_idx: Option<BybitPositionIdx>,
2010        bbo_side_type: Option<BybitBboSideType>,
2011        bbo_level: Option<String>,
2012        smp_type: Option<BybitOrderSmpType>,
2013    ) -> BybitWsResult<BybitWsPlaceOrderParams> {
2014        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
2015            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
2016        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
2017
2018        let bybit_side = match order_side {
2019            OrderSide::Buy => BybitOrderSide::Buy,
2020            OrderSide::Sell => BybitOrderSide::Sell,
2021        };
2022
2023        let (bybit_order_type, is_stop_order) = match order_type {
2024            OrderType::Market => (BybitOrderType::Market, false),
2025            OrderType::Limit => (BybitOrderType::Limit, false),
2026            OrderType::StopMarket | OrderType::MarketIfTouched => (BybitOrderType::Market, true),
2027            OrderType::StopLimit | OrderType::LimitIfTouched => (BybitOrderType::Limit, true),
2028            _ => {
2029                return Err(BybitWsError::ClientError(format!(
2030                    "Unsupported order type: {order_type:?}"
2031                )));
2032            }
2033        };
2034
2035        let bybit_tif =
2036            map_time_in_force(bybit_order_type, time_in_force, post_only).map_err(|tif| {
2037                BybitWsError::ClientError(format!("Unsupported time in force: {tif:?}"))
2038            })?;
2039        let market_unit = spot_market_unit(product_type, bybit_order_type, is_quote_quantity);
2040        let is_leverage_value = spot_leverage(product_type, is_leverage);
2041        let trigger_dir =
2042            trigger_direction(order_type, order_side, is_stop_order).map(|d| d as i32);
2043
2044        let params = if is_stop_order {
2045            BybitWsPlaceOrderParams {
2046                category: product_type,
2047                symbol: raw_symbol,
2048                side: bybit_side,
2049                order_type: bybit_order_type,
2050                qty: quantity.to_string(),
2051                is_leverage: is_leverage_value,
2052                market_unit,
2053                price: if bbo_side_type.is_some() {
2054                    None
2055                } else {
2056                    price.map(|p| p.to_string())
2057                },
2058                time_in_force: bybit_tif,
2059                order_link_id: Some(client_order_id.to_string()),
2060                reduce_only: reduce_only.filter(|&r| r),
2061                close_on_trigger: None,
2062                trigger_price: trigger_price.map(|p| p.to_string()),
2063                trigger_by: Some(resolve_trigger_type(trigger_type)),
2064                trigger_direction: trigger_dir,
2065                tpsl_mode: if take_profit.is_some() || stop_loss.is_some() {
2066                    Some(BybitTpSlMode::Full)
2067                } else {
2068                    None
2069                },
2070                take_profit: take_profit.map(|p| p.to_string()),
2071                stop_loss: stop_loss.map(|p| p.to_string()),
2072                tp_trigger_by: take_profit.map(|_| resolve_trigger_type(trigger_type)),
2073                sl_trigger_by: stop_loss.map(|_| resolve_trigger_type(trigger_type)),
2074                sl_trigger_price: None,
2075                tp_trigger_price: None,
2076                sl_order_type: None,
2077                tp_order_type: None,
2078                sl_limit_price: None,
2079                tp_limit_price: None,
2080                order_iv: None,
2081                smp_type,
2082                mmp: None,
2083                position_idx,
2084                bbo_side_type,
2085                bbo_level,
2086            }
2087        } else {
2088            BybitWsPlaceOrderParams {
2089                category: product_type,
2090                symbol: raw_symbol,
2091                side: bybit_side,
2092                order_type: bybit_order_type,
2093                qty: quantity.to_string(),
2094                is_leverage: is_leverage_value,
2095                market_unit,
2096                price: if bbo_side_type.is_some() {
2097                    None
2098                } else {
2099                    price.map(|p| p.to_string())
2100                },
2101                time_in_force: bybit_tif,
2102                order_link_id: Some(client_order_id.to_string()),
2103                reduce_only: reduce_only.filter(|&r| r),
2104                close_on_trigger: None,
2105                trigger_price: None,
2106                trigger_by: None,
2107                trigger_direction: None,
2108                tpsl_mode: if take_profit.is_some() || stop_loss.is_some() {
2109                    Some(BybitTpSlMode::Full)
2110                } else {
2111                    None
2112                },
2113                take_profit: take_profit.map(|p| p.to_string()),
2114                stop_loss: stop_loss.map(|p| p.to_string()),
2115                tp_trigger_by: take_profit.map(|_| resolve_trigger_type(trigger_type)),
2116                sl_trigger_by: stop_loss.map(|_| resolve_trigger_type(trigger_type)),
2117                sl_trigger_price: None,
2118                tp_trigger_price: None,
2119                sl_order_type: None,
2120                tp_order_type: None,
2121                sl_limit_price: None,
2122                tp_limit_price: None,
2123                order_iv: None,
2124                smp_type,
2125                mmp: None,
2126                position_idx,
2127                bbo_side_type,
2128                bbo_level,
2129            }
2130        };
2131
2132        Ok(params)
2133    }
2134
2135    /// Builds order params for amending an order.
2136    pub fn build_amend_order_params(
2137        &self,
2138        product_type: BybitProductType,
2139        instrument_id: InstrumentId,
2140        venue_order_id: Option<VenueOrderId>,
2141        client_order_id: Option<ClientOrderId>,
2142        quantity: Option<Quantity>,
2143        price: Option<Price>,
2144    ) -> BybitWsResult<BybitWsAmendOrderParams> {
2145        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
2146            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
2147        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
2148
2149        Ok(BybitWsAmendOrderParams {
2150            category: product_type,
2151            symbol: raw_symbol,
2152            order_id: venue_order_id.map(|v| v.to_string()),
2153            order_link_id: client_order_id.map(|c| c.to_string()),
2154            qty: quantity.map(|q| q.to_string()),
2155            price: price.map(|p| p.to_string()),
2156            trigger_price: None,
2157            take_profit: None,
2158            stop_loss: None,
2159            tp_trigger_by: None,
2160            sl_trigger_by: None,
2161            order_iv: None,
2162        })
2163    }
2164
2165    /// Builds order params for canceling an order via WebSocket.
2166    ///
2167    /// # Errors
2168    ///
2169    /// Returns an error if symbol parsing fails or if neither venue_order_id
2170    /// nor client_order_id is provided.
2171    pub fn build_cancel_order_params(
2172        &self,
2173        product_type: BybitProductType,
2174        instrument_id: InstrumentId,
2175        venue_order_id: Option<VenueOrderId>,
2176        client_order_id: Option<ClientOrderId>,
2177    ) -> BybitWsResult<BybitWsCancelOrderParams> {
2178        if venue_order_id.is_none() && client_order_id.is_none() {
2179            return Err(BybitWsError::ClientError(
2180                "Either venue_order_id or client_order_id must be provided".to_string(),
2181            ));
2182        }
2183
2184        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
2185            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
2186        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
2187
2188        Ok(BybitWsCancelOrderParams {
2189            category: product_type,
2190            symbol: raw_symbol,
2191            order_id: venue_order_id.map(|v| v.to_string()),
2192            order_link_id: client_order_id.map(|c| c.to_string()),
2193        })
2194    }
2195
2196    fn include_referer_header(&self, time_in_force: Option<BybitTimeInForce>) -> bool {
2197        let is_post_only = matches!(time_in_force, Some(BybitTimeInForce::PostOnly));
2198        let mm_level = self.mm_level.load(Ordering::Relaxed);
2199        !(is_post_only && mm_level > 0)
2200    }
2201
2202    fn default_headers() -> Vec<(String, String)> {
2203        let mut headers = create_standard_nautilus_headers();
2204        headers.push(("Content-Type".to_string(), "application/json".to_string()));
2205        headers
2206    }
2207
2208    async fn authenticate_if_required(&self) -> BybitWsResult<()> {
2209        if !self.requires_auth {
2210            return Ok(());
2211        }
2212
2213        let credential = self.credential.as_ref().ok_or_else(|| {
2214            BybitWsError::Authentication("Credentials required for authentication".to_string())
2215        })?;
2216
2217        let expires = jiff::Timestamp::now().as_millisecond() + WEBSOCKET_AUTH_WINDOW_MS;
2218        let signature = credential.sign_websocket_auth(expires);
2219
2220        let auth_message = Zeroizing::new(BybitAuthRequest {
2221            op: BybitWsOperation::Auth,
2222            args: vec![
2223                Value::String(credential.api_key().to_string()),
2224                Value::Number(expires.into()),
2225                Value::String(signature),
2226            ],
2227        });
2228
2229        let payload = SecretString::from(serde_json::to_string(&*auth_message)?);
2230        drop(auth_message);
2231
2232        // Begin auth attempt so succeed() will update state
2233        let _rx = self.auth_tracker.begin();
2234
2235        self.cmd_tx
2236            .read()
2237            .await
2238            .send(HandlerCommand::Authenticate { payload })
2239            .map_err(|e| BybitWsError::Send(format!("Failed to send auth command: {e}")))?;
2240
2241        Ok(())
2242    }
2243
2244    async fn send_cmd(&self, cmd: HandlerCommand) -> BybitWsResult<()> {
2245        self.cmd_tx
2246            .read()
2247            .await
2248            .send(cmd)
2249            .map_err(|e| BybitWsError::Send(e.to_string()))
2250    }
2251}
2252
2253impl Drop for BybitWebSocketClient {
2254    fn drop(&mut self) {
2255        if Arc::strong_count(&self.task_handle) == 1 && !self.task_handle.is_empty() {
2256            self.cancellation_token.load().cancel();
2257            self.signal.store(true, Ordering::Relaxed);
2258            self.task_handle.abort();
2259        }
2260    }
2261}
2262
2263fn orderbook_topic(instrument_id: InstrumentId, depth: u32) -> String {
2264    let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
2265    format!(
2266        "{}.{depth}.{raw_symbol}",
2267        BybitWsPublicChannel::OrderBook.as_ref()
2268    )
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273    use futures_util::StreamExt;
2274    use nautilus_testkit::connection::race_command_with_publication;
2275    use rstest::rstest;
2276
2277    use super::*;
2278    use crate::{
2279        common::{enums::BybitMarketUnit, testing::load_test_json},
2280        websocket::{messages::BybitWsFrame, parse_bybit_ws_frame},
2281    };
2282
2283    #[tokio::test]
2284    async fn command_during_sender_publication_reaches_initialized_handler() {
2285        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2286        let addr = listener.local_addr().unwrap();
2287        let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
2288
2289        let server = tokio::spawn(async move {
2290            let (socket, _) = listener.accept().await.unwrap();
2291            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
2292            while let Some(Ok(message)) = socket.next().await {
2293                if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
2294                    let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
2295                    let _ = received_tx.send(payload);
2296                }
2297            }
2298        });
2299
2300        let url = format!("ws://{addr}");
2301        let mut ws_client = BybitWebSocketClient::new_public(Some(url), 30);
2302        let observer = ws_client.clone();
2303        let expected = serde_json::json!({
2304            "op": "subscribe",
2305            "args": [
2306                "publicTrade.BTCUSDT"
2307            ]
2308        });
2309
2310        let command = HandlerCommand::Subscribe {
2311            topics: vec![expected.to_string()],
2312        };
2313
2314        race_command_with_publication(
2315            Arc::clone(&ws_client.cmd_tx),
2316            ws_client.connect(),
2317            move || command,
2318            || observer.is_active(),
2319        )
2320        .await
2321        .expect("connect failed");
2322        let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
2323        ws_client.close().await.expect("disconnect failed");
2324        server.abort();
2325
2326        let payload = received
2327            .expect("startup command must reach server")
2328            .unwrap();
2329        assert_eq!(payload, expected);
2330    }
2331
2332    #[rstest]
2333    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
2334    #[case(
2335        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
2336        "wss://stream.example.com:9443"
2337    )]
2338    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
2339        let client = BybitWebSocketClient::new_public(Some(url.to_string()), 30);
2340        let debug = format!("{client:?}");
2341        assert!(debug.contains(&format!("url: {origin:?}")));
2342        assert!(!debug.contains("SYN-"));
2343        assert_eq!(client.url.expose(), url);
2344    }
2345
2346    #[tokio::test]
2347    async fn test_drop_clone_does_not_cancel_handler() {
2348        let client = BybitWebSocketClient::new_public(Some("wss://test".to_string()), 30);
2349        let cancellation_token = CancellationToken::new();
2350        client
2351            .cancellation_token
2352            .store(Arc::new(cancellation_token.clone()));
2353        client
2354            .task_handle
2355            .insert(get_runtime().spawn(std::future::pending()));
2356        let clone = client.clone();
2357
2358        drop(clone);
2359
2360        assert!(!cancellation_token.is_cancelled());
2361        assert!(!client.task_handle.is_empty());
2362    }
2363
2364    // An unconnected client rejects every command, so success proves nothing was sent
2365    #[tokio::test]
2366    async fn test_subscribe_book_opens_gate_for_held_topic_without_sending() {
2367        let client = BybitWebSocketClient::new_public(None, 20);
2368        let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2369        let topic = "orderbook.1.BTCUSDT";
2370        assert!(client.subscriptions.add_reference(topic));
2371        let gate = SnapshotGate::default();
2372        gate.lock().close();
2373
2374        let result = client
2375            .subscribe_book(instrument_id, 1, CancellationToken::new(), gate.clone())
2376            .await;
2377
2378        assert!(result.is_ok());
2379        assert!(!gate.lock().is_closed());
2380        assert_eq!(client.subscriptions.get_reference_count(topic), 2);
2381    }
2382
2383    #[tokio::test]
2384    async fn test_subscribe_book_keeps_gate_closed_when_send_fails() {
2385        let client = BybitWebSocketClient::new_public(None, 20);
2386        let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2387        let gate = SnapshotGate::default();
2388        gate.lock().close();
2389
2390        let error = client
2391            .subscribe_book(instrument_id, 50, CancellationToken::new(), gate.clone())
2392            .await
2393            .unwrap_err();
2394
2395        assert_eq!(
2396            error.to_string(),
2397            "WebSocket send error: Failed to send subscribe command: channel closed"
2398        );
2399        assert!(gate.lock().is_closed());
2400        assert_eq!(
2401            client
2402                .subscriptions
2403                .get_reference_count("orderbook.50.BTCUSDT"),
2404            1
2405        );
2406    }
2407
2408    #[rstest]
2409    fn classify_orderbook_snapshot() {
2410        let json: Value = serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json"))
2411            .expect("invalid fixture");
2412        let frame = parse_bybit_ws_frame(json);
2413        assert!(matches!(frame, BybitWsFrame::Orderbook(_)));
2414    }
2415
2416    #[rstest]
2417    fn classify_trade_snapshot() {
2418        let json: Value =
2419            serde_json::from_str(&load_test_json("ws_public_trade.json")).expect("invalid fixture");
2420        let frame = parse_bybit_ws_frame(json);
2421        assert!(matches!(frame, BybitWsFrame::Trade(_)));
2422    }
2423
2424    #[rstest]
2425    fn classify_ticker_linear_snapshot() {
2426        let json: Value = serde_json::from_str(&load_test_json("ws_ticker_linear.json"))
2427            .expect("invalid fixture");
2428        let frame = parse_bybit_ws_frame(json);
2429        assert!(matches!(frame, BybitWsFrame::TickerLinear(_)));
2430    }
2431
2432    #[rstest]
2433    fn classify_ticker_option_snapshot() {
2434        let json: Value = serde_json::from_str(&load_test_json("ws_ticker_option.json"))
2435            .expect("invalid fixture");
2436        let frame = parse_bybit_ws_frame(json);
2437        assert!(matches!(frame, BybitWsFrame::TickerOption(_)));
2438    }
2439
2440    #[rstest]
2441    fn test_race_unsubscribe_failure_recovery() {
2442        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2443        let topic = "publicTrade.BTCUSDT";
2444
2445        subscriptions.mark_subscribe(topic);
2446        subscriptions.confirm_subscribe(topic);
2447        assert_eq!(subscriptions.len(), 1);
2448
2449        subscriptions.mark_unsubscribe(topic);
2450        assert_eq!(subscriptions.len(), 0);
2451        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2452
2453        subscriptions.confirm_unsubscribe(topic);
2454        subscriptions.mark_subscribe(topic);
2455        subscriptions.confirm_subscribe(topic);
2456
2457        assert_eq!(subscriptions.len(), 1);
2458        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2459        assert!(subscriptions.pending_subscribe_topics().is_empty());
2460
2461        let all = subscriptions.all_topics();
2462        assert_eq!(all.len(), 1);
2463        assert!(all.contains(&topic.to_string()));
2464    }
2465
2466    #[rstest]
2467    fn test_race_resubscribe_before_unsubscribe_ack() {
2468        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2469        let topic = "orderbook.50.BTCUSDT";
2470
2471        subscriptions.mark_subscribe(topic);
2472        subscriptions.confirm_subscribe(topic);
2473        assert_eq!(subscriptions.len(), 1);
2474
2475        subscriptions.mark_unsubscribe(topic);
2476        assert_eq!(subscriptions.len(), 0);
2477        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2478
2479        subscriptions.mark_subscribe(topic);
2480        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2481
2482        subscriptions.confirm_unsubscribe(topic);
2483        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2484        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2485
2486        subscriptions.confirm_subscribe(topic);
2487        assert_eq!(subscriptions.len(), 1);
2488        assert!(subscriptions.pending_subscribe_topics().is_empty());
2489
2490        let all = subscriptions.all_topics();
2491        assert_eq!(all.len(), 1);
2492        assert!(all.contains(&topic.to_string()));
2493    }
2494
2495    #[rstest]
2496    fn test_race_late_subscribe_confirmation_after_unsubscribe() {
2497        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2498        let topic = "tickers.ETHUSDT";
2499
2500        subscriptions.mark_subscribe(topic);
2501        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2502
2503        subscriptions.mark_unsubscribe(topic);
2504        assert!(subscriptions.pending_subscribe_topics().is_empty());
2505        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2506
2507        subscriptions.confirm_subscribe(topic);
2508        assert_eq!(subscriptions.len(), 0);
2509        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2510
2511        subscriptions.confirm_unsubscribe(topic);
2512
2513        assert!(subscriptions.is_empty());
2514        assert!(subscriptions.all_topics().is_empty());
2515    }
2516
2517    #[rstest]
2518    fn test_race_reconnection_with_pending_states() {
2519        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2520
2521        let trade_btc = "publicTrade.BTCUSDT";
2522        subscriptions.mark_subscribe(trade_btc);
2523        subscriptions.confirm_subscribe(trade_btc);
2524
2525        let trade_eth = "publicTrade.ETHUSDT";
2526        subscriptions.mark_subscribe(trade_eth);
2527
2528        let book_btc = "orderbook.50.BTCUSDT";
2529        subscriptions.mark_subscribe(book_btc);
2530        subscriptions.confirm_subscribe(book_btc);
2531        subscriptions.mark_unsubscribe(book_btc);
2532
2533        let topics_to_restore = subscriptions.all_topics();
2534
2535        assert_eq!(topics_to_restore.len(), 2);
2536        assert!(topics_to_restore.contains(&trade_btc.to_string()));
2537        assert!(topics_to_restore.contains(&trade_eth.to_string()));
2538        assert!(!topics_to_restore.contains(&book_btc.to_string()));
2539    }
2540
2541    #[tokio::test]
2542    async fn option_limit_counts_pending_unsubscriptions() {
2543        let client = BybitWebSocketClient::new_public_with(
2544            BybitProductType::Option,
2545            BybitEnvironment::Mainnet,
2546            Some("ws://option-pending-limit.invalid/v5/public/option".to_string()),
2547            20,
2548            TransportBackend::default(),
2549            None,
2550        );
2551
2552        for index in 0..BYBIT_OPTION_SUBSCRIPTION_LIMIT {
2553            let topic = format!("tickers.OPTION-{index}");
2554            assert!(client.subscriptions.add_reference(&topic));
2555            client.subscriptions.mark_subscribe(&topic);
2556            client.subscriptions.confirm_subscribe(&topic);
2557        }
2558        let pending = "tickers.OPTION-0";
2559        assert!(client.subscriptions.remove_reference(pending));
2560        client.subscriptions.mark_unsubscribe(pending);
2561
2562        let new_topic = "tickers.OPTION-new";
2563        let error = client
2564            .subscribe(vec![new_topic.to_string()])
2565            .await
2566            .unwrap_err();
2567
2568        assert!(error.to_string().contains("2000 arguments"));
2569        assert_eq!(client.subscriptions.get_reference_count(new_topic), 0);
2570        assert_eq!(
2571            client.subscriptions.pending_unsubscribe_topics(),
2572            vec![pending]
2573        );
2574    }
2575
2576    #[tokio::test]
2577    async fn batch_chunks_enter_handler_atomically() {
2578        let client = BybitWebSocketClient::new_trade(
2579            BybitEnvironment::Testnet,
2580            Some("test-key".to_string()),
2581            Some("test-secret".to_string()),
2582            None,
2583            20,
2584            TransportBackend::default(),
2585            None,
2586        );
2587        client
2588            .connection_mode
2589            .load()
2590            .store(ConnectionMode::Active.as_u8(), Ordering::Release);
2591        client.auth_tracker.succeed();
2592        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2593        *client.cmd_tx.write().await = cmd_tx;
2594        let orders = (0..21)
2595            .map(|index| BybitWsCancelOrderParams {
2596                category: BybitProductType::Linear,
2597                symbol: Ustr::from("BTCUSDT"),
2598                order_id: Some(format!("order-{index}")),
2599                order_link_id: Some(format!("client-order-{index}")),
2600            })
2601            .collect::<Vec<_>>();
2602        let req_ids =
2603            BybitWebSocketClient::batch_request_ids(BybitProductType::Linear, orders.len());
2604
2605        client
2606            .batch_cancel_orders_with_ids(orders, req_ids.clone())
2607            .await
2608            .unwrap();
2609
2610        let command = cmd_rx.recv().await.expect("expected batch command");
2611        let HandlerCommand::SendOrders { commands } = command else {
2612            panic!("expected atomic batch command, was {command:?}");
2613        };
2614        assert_eq!(commands.len(), 3);
2615        assert_eq!(
2616            commands
2617                .iter()
2618                .map(|command| command.req_id.as_str())
2619                .collect::<Vec<_>>(),
2620            req_ids.iter().map(String::as_str).collect::<Vec<_>>()
2621        );
2622        assert_eq!(
2623            commands
2624                .iter()
2625                .map(|command| command.weight)
2626                .collect::<Vec<_>>(),
2627            vec![10, 10, 1]
2628        );
2629        assert!(cmd_rx.try_recv().is_err());
2630    }
2631
2632    #[tokio::test]
2633    async fn option_batch_chunks_preserve_request_correlation() {
2634        let client = BybitWebSocketClient::new_trade(
2635            BybitEnvironment::Testnet,
2636            Some("test-key".to_string()),
2637            Some("test-secret".to_string()),
2638            None,
2639            20,
2640            TransportBackend::default(),
2641            None,
2642        );
2643        client
2644            .connection_mode
2645            .load()
2646            .store(ConnectionMode::Active.as_u8(), Ordering::Release);
2647        client.auth_tracker.succeed();
2648        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2649        *client.cmd_tx.write().await = cmd_tx;
2650
2651        let place_template = BybitWsPlaceOrderParams {
2652            category: BybitProductType::Option,
2653            symbol: Ustr::from("BTC-30JUN25-100000-C"),
2654            side: BybitOrderSide::Buy,
2655            order_type: BybitOrderType::Limit,
2656            qty: "0.1".to_string(),
2657            is_leverage: None,
2658            market_unit: None,
2659            price: Some("500".to_string()),
2660            time_in_force: Some(BybitTimeInForce::Gtc),
2661            order_link_id: None,
2662            reduce_only: None,
2663            close_on_trigger: None,
2664            trigger_price: None,
2665            trigger_by: None,
2666            trigger_direction: None,
2667            tpsl_mode: None,
2668            take_profit: None,
2669            stop_loss: None,
2670            tp_trigger_by: None,
2671            sl_trigger_by: None,
2672            sl_trigger_price: None,
2673            tp_trigger_price: None,
2674            sl_order_type: None,
2675            tp_order_type: None,
2676            sl_limit_price: None,
2677            tp_limit_price: None,
2678            order_iv: Some("0.80".to_string()),
2679            smp_type: None,
2680            mmp: Some(true),
2681            position_idx: None,
2682            bbo_side_type: None,
2683            bbo_level: None,
2684        };
2685        let place_order_link_ids = (0..6)
2686            .map(|index| format!("option-place-{index}"))
2687            .collect::<Vec<_>>();
2688        let place_orders = place_order_link_ids
2689            .iter()
2690            .map(|order_link_id| BybitWsPlaceOrderParams {
2691                order_link_id: Some(order_link_id.clone()),
2692                ..place_template.clone()
2693            })
2694            .collect::<Vec<_>>();
2695        let place_req_ids =
2696            BybitWebSocketClient::batch_request_ids(BybitProductType::Option, place_orders.len());
2697
2698        client
2699            .batch_place_orders_with_ids(place_orders, place_req_ids.clone())
2700            .await
2701            .unwrap();
2702
2703        let command = cmd_rx.recv().await.expect("expected place batch command");
2704        let HandlerCommand::SendOrders { commands } = command else {
2705            panic!("expected atomic place batch command, was {command:?}");
2706        };
2707        assert_option_batch_commands(
2708            &commands,
2709            &place_req_ids,
2710            BybitWsOrderRequestOp::CreateBatch,
2711            &place_order_link_ids,
2712            batch_nested_items,
2713        );
2714
2715        let amend_template = BybitWsAmendOrderParams {
2716            category: BybitProductType::Option,
2717            symbol: Ustr::from("BTC-30JUN25-100000-C"),
2718            order_id: Some("venue-option-amend".to_string()),
2719            order_link_id: None,
2720            qty: Some("0.23".to_string()),
2721            price: Some("510.5".to_string()),
2722            trigger_price: Some("505.5".to_string()),
2723            take_profit: Some("530.5".to_string()),
2724            stop_loss: Some("490.5".to_string()),
2725            tp_trigger_by: Some(crate::common::enums::BybitTriggerType::MarkPrice),
2726            sl_trigger_by: Some(crate::common::enums::BybitTriggerType::IndexPrice),
2727            order_iv: Some("0.91".to_string()),
2728        };
2729        let amend_order_link_ids = (0..6)
2730            .map(|index| format!("option-amend-{index}"))
2731            .collect::<Vec<_>>();
2732        let amend_orders = amend_order_link_ids
2733            .iter()
2734            .map(|order_link_id| BybitWsAmendOrderParams {
2735                order_link_id: Some(order_link_id.clone()),
2736                ..amend_template.clone()
2737            })
2738            .collect::<Vec<_>>();
2739        let amend_req_ids =
2740            BybitWebSocketClient::batch_request_ids(BybitProductType::Option, amend_orders.len());
2741
2742        client
2743            .batch_amend_orders_with_ids(amend_orders, amend_req_ids.clone())
2744            .await
2745            .unwrap();
2746
2747        let command = cmd_rx.recv().await.expect("expected amend batch command");
2748        let HandlerCommand::SendOrders { commands } = command else {
2749            panic!("expected atomic amend batch command, was {command:?}");
2750        };
2751        assert_option_batch_commands(
2752            &commands,
2753            &amend_req_ids,
2754            BybitWsOrderRequestOp::AmendBatch,
2755            &amend_order_link_ids,
2756            batch_nested_items,
2757        );
2758        assert!(commands.iter().all(|command| command.args.len() == 1));
2759        assert!(
2760            commands
2761                .iter()
2762                .all(|command| command.args[0]["category"] == "option")
2763        );
2764        assert_eq!(
2765            commands[0].args[0]["request"][0],
2766            serde_json::json!({
2767                "symbol": "BTC-30JUN25-100000-C",
2768                "orderId": "venue-option-amend",
2769                "orderLinkId": "option-amend-0",
2770                "qty": "0.23",
2771                "price": "510.5",
2772                "triggerPrice": "505.5",
2773                "takeProfit": "530.5",
2774                "stopLoss": "490.5",
2775                "tpTriggerBy": "MarkPrice",
2776                "slTriggerBy": "IndexPrice",
2777                "orderIv": "0.91",
2778            })
2779        );
2780        assert!(
2781            commands
2782                .iter()
2783                .flat_map(batch_nested_items)
2784                .all(|order| order.get("category").is_none())
2785        );
2786
2787        let cancel_order_link_ids = (0..6)
2788            .map(|index| format!("option-cancel-{index}"))
2789            .collect::<Vec<_>>();
2790        let cancel_orders = cancel_order_link_ids
2791            .iter()
2792            .enumerate()
2793            .map(|(index, order_link_id)| BybitWsCancelOrderParams {
2794                category: BybitProductType::Option,
2795                symbol: Ustr::from("BTC-30JUN25-100000-C"),
2796                order_id: Some(format!("venue-option-{index}")),
2797                order_link_id: Some(order_link_id.clone()),
2798            })
2799            .collect::<Vec<_>>();
2800        let cancel_req_ids =
2801            BybitWebSocketClient::batch_request_ids(BybitProductType::Option, cancel_orders.len());
2802
2803        client
2804            .batch_cancel_orders_with_ids(cancel_orders, cancel_req_ids.clone())
2805            .await
2806            .unwrap();
2807
2808        let command = cmd_rx.recv().await.expect("expected cancel batch command");
2809        let HandlerCommand::SendOrders { commands } = command else {
2810            panic!("expected atomic cancel batch command, was {command:?}");
2811        };
2812        assert_option_batch_commands(
2813            &commands,
2814            &cancel_req_ids,
2815            BybitWsOrderRequestOp::CancelBatch,
2816            &cancel_order_link_ids,
2817            batch_nested_items,
2818        );
2819        assert!(cmd_rx.try_recv().is_err());
2820    }
2821
2822    fn assert_option_batch_commands(
2823        commands: &[BybitWsOrderCommand],
2824        req_ids: &[String],
2825        op: BybitWsOrderRequestOp,
2826        order_link_ids: &[String],
2827        items: for<'a> fn(&'a BybitWsOrderCommand) -> &'a [Value],
2828    ) {
2829        assert_eq!(req_ids.len(), 2);
2830        assert_ne!(req_ids[0], req_ids[1]);
2831        assert_eq!(commands.len(), 2);
2832        assert_eq!(
2833            commands
2834                .iter()
2835                .map(|command| command.req_id.as_str())
2836                .collect::<Vec<_>>(),
2837            req_ids.iter().map(String::as_str).collect::<Vec<_>>()
2838        );
2839        assert!(commands.iter().all(|command| command.op == op));
2840        assert_eq!(
2841            commands
2842                .iter()
2843                .map(|command| items(command).len())
2844                .collect::<Vec<_>>(),
2845            vec![5, 1]
2846        );
2847        assert_eq!(
2848            commands
2849                .iter()
2850                .flat_map(items)
2851                .map(|order| order["orderLinkId"].as_str().unwrap().to_string())
2852                .collect::<Vec<_>>(),
2853            order_link_ids
2854        );
2855        assert!(commands.iter().all(|command| command.weight == 1));
2856    }
2857
2858    fn batch_nested_items(command: &BybitWsOrderCommand) -> &[Value] {
2859        command.args[0]["request"].as_array().unwrap()
2860    }
2861
2862    #[rstest]
2863    fn batch_place_command_carries_smp_type_per_order() {
2864        let client = BybitWebSocketClient::new_trade(
2865            BybitEnvironment::Testnet,
2866            Some("test-key".to_string()),
2867            Some("test-secret".to_string()),
2868            None,
2869            20,
2870            TransportBackend::default(),
2871            None,
2872        );
2873
2874        let expectations = [
2875            (Some(BybitOrderSmpType::CancelMaker), Some("CancelMaker")),
2876            (Some(BybitOrderSmpType::CancelBoth), Some("CancelBoth")),
2877            (None, None),
2878        ];
2879
2880        let orders = expectations
2881            .into_iter()
2882            .enumerate()
2883            .map(|(index, (smp_type, _))| {
2884                client
2885                    .build_place_order_params(
2886                        BybitProductType::Linear,
2887                        InstrumentId::from("ETHUSDT-LINEAR.BYBIT"),
2888                        ClientOrderId::from(format!("smp-batch-{index}").as_str()),
2889                        OrderSide::Buy,
2890                        OrderType::Limit,
2891                        Quantity::from("1.0"),
2892                        false,
2893                        Some(TimeInForce::Gtc),
2894                        Some(Price::from("50000.0")),
2895                        None,
2896                        None,
2897                        None,
2898                        None,
2899                        false,
2900                        None,
2901                        None,
2902                        None,
2903                        None,
2904                        None,
2905                        smp_type,
2906                    )
2907                    .expect("failed to build params")
2908            })
2909            .collect::<Vec<_>>();
2910
2911        let command = client
2912            .build_batch_place_command(orders, "req-smp-batch".to_string())
2913            .expect("failed to build batch command");
2914
2915        let items = batch_nested_items(&command);
2916
2917        assert_eq!(items.len(), expectations.len());
2918
2919        for (item, (_, expected)) in items.iter().zip(expectations) {
2920            assert_eq!(item.get("smpType").and_then(Value::as_str), expected);
2921        }
2922    }
2923
2924    #[rstest]
2925    fn test_race_duplicate_subscribe_messages_idempotent() {
2926        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2927        let topic = "publicTrade.BTCUSDT";
2928
2929        subscriptions.mark_subscribe(topic);
2930        subscriptions.confirm_subscribe(topic);
2931        assert_eq!(subscriptions.len(), 1);
2932
2933        subscriptions.mark_subscribe(topic);
2934        assert!(subscriptions.pending_subscribe_topics().is_empty());
2935        assert_eq!(subscriptions.len(), 1);
2936
2937        subscriptions.confirm_subscribe(topic);
2938        assert_eq!(subscriptions.len(), 1);
2939
2940        let all = subscriptions.all_topics();
2941        assert_eq!(all.len(), 1);
2942        assert_eq!(all[0], topic);
2943    }
2944
2945    #[rstest]
2946    #[case::spot_with_leverage(BybitProductType::Spot, true, Some(1))]
2947    #[case::spot_without_leverage(BybitProductType::Spot, false, Some(0))]
2948    #[case::linear_with_leverage(BybitProductType::Linear, true, None)]
2949    #[case::linear_without_leverage(BybitProductType::Linear, false, None)]
2950    #[case::inverse_with_leverage(BybitProductType::Inverse, true, None)]
2951    #[case::option_with_leverage(BybitProductType::Option, true, None)]
2952    fn test_is_leverage_parameter(
2953        #[case] product_type: BybitProductType,
2954        #[case] is_leverage: bool,
2955        #[case] expected: Option<i32>,
2956    ) {
2957        let symbol = match product_type {
2958            BybitProductType::Spot => "BTCUSDT-SPOT.BYBIT",
2959            BybitProductType::Linear => "ETHUSDT-LINEAR.BYBIT",
2960            BybitProductType::Inverse => "BTCUSD-INVERSE.BYBIT",
2961            BybitProductType::Option => "BTC-31MAY24-50000-C-OPTION.BYBIT",
2962        };
2963
2964        let instrument_id = InstrumentId::from(symbol);
2965        let client_order_id = ClientOrderId::from("test-order-1");
2966        let quantity = Quantity::from("1.0");
2967
2968        let client = BybitWebSocketClient::new_trade(
2969            BybitEnvironment::Testnet,
2970            Some("test-key".to_string()),
2971            Some("test-secret".to_string()),
2972            None,
2973            20,
2974            TransportBackend::default(),
2975            None,
2976        );
2977
2978        let params = client
2979            .build_place_order_params(
2980                product_type,
2981                instrument_id,
2982                client_order_id,
2983                OrderSide::Buy,
2984                OrderType::Limit,
2985                quantity,
2986                false,
2987                Some(TimeInForce::Gtc),
2988                Some(Price::from("50000.0")),
2989                None,
2990                None,
2991                None,
2992                None,
2993                is_leverage,
2994                None,
2995                None,
2996                None,
2997                None,
2998                None,
2999                None,
3000            )
3001            .expect("Failed to build params");
3002
3003        assert_eq!(params.is_leverage, expected);
3004    }
3005
3006    #[rstest]
3007    #[case::spot_market_quote_quantity(
3008        BybitProductType::Spot,
3009        OrderType::Market,
3010        true,
3011        Some(BybitMarketUnit::QuoteCoin)
3012    )]
3013    #[case::spot_market_base_quantity(
3014        BybitProductType::Spot,
3015        OrderType::Market,
3016        false,
3017        Some(BybitMarketUnit::BaseCoin)
3018    )]
3019    #[case::spot_limit_no_unit(BybitProductType::Spot, OrderType::Limit, false, None)]
3020    #[case::spot_limit_quote(BybitProductType::Spot, OrderType::Limit, true, None)]
3021    #[case::linear_market_no_unit(BybitProductType::Linear, OrderType::Market, false, None)]
3022    #[case::inverse_market_no_unit(BybitProductType::Inverse, OrderType::Market, true, None)]
3023    fn test_is_quote_quantity_parameter(
3024        #[case] product_type: BybitProductType,
3025        #[case] order_type: OrderType,
3026        #[case] is_quote_quantity: bool,
3027        #[case] expected: Option<BybitMarketUnit>,
3028    ) {
3029        let symbol = match product_type {
3030            BybitProductType::Spot => "BTCUSDT-SPOT.BYBIT",
3031            BybitProductType::Linear => "ETHUSDT-LINEAR.BYBIT",
3032            BybitProductType::Inverse => "BTCUSD-INVERSE.BYBIT",
3033            BybitProductType::Option => "BTC-31MAY24-50000-C-OPTION.BYBIT",
3034        };
3035
3036        let instrument_id = InstrumentId::from(symbol);
3037        let client_order_id = ClientOrderId::from("test-order-1");
3038        let quantity = Quantity::from("1.0");
3039
3040        let client = BybitWebSocketClient::new_trade(
3041            BybitEnvironment::Testnet,
3042            Some("test-key".to_string()),
3043            Some("test-secret".to_string()),
3044            None,
3045            20,
3046            TransportBackend::default(),
3047            None,
3048        );
3049
3050        let params = client
3051            .build_place_order_params(
3052                product_type,
3053                instrument_id,
3054                client_order_id,
3055                OrderSide::Buy,
3056                order_type,
3057                quantity,
3058                is_quote_quantity,
3059                Some(TimeInForce::Gtc),
3060                if order_type == OrderType::Market {
3061                    None
3062                } else {
3063                    Some(Price::from("50000.0"))
3064                },
3065                None,
3066                None,
3067                None,
3068                None,
3069                false,
3070                None,
3071                None,
3072                None,
3073                None,
3074                None,
3075                None,
3076            )
3077            .expect("Failed to build params");
3078
3079        assert_eq!(params.market_unit, expected);
3080    }
3081
3082    #[rstest]
3083    fn test_build_place_order_params_with_bbo_omits_price() {
3084        let client = BybitWebSocketClient::new_trade(
3085            BybitEnvironment::Testnet,
3086            Some("test-key".to_string()),
3087            Some("test-secret".to_string()),
3088            None,
3089            20,
3090            TransportBackend::default(),
3091            None,
3092        );
3093
3094        let params = client
3095            .build_place_order_params(
3096                BybitProductType::Linear,
3097                InstrumentId::from("ETHUSDT-LINEAR.BYBIT"),
3098                ClientOrderId::from("test-bbo-order-1"),
3099                OrderSide::Buy,
3100                OrderType::Limit,
3101                Quantity::from("1.0"),
3102                false,
3103                Some(TimeInForce::Gtc),
3104                Some(Price::from("50000.0")),
3105                None,
3106                None,
3107                None,
3108                None,
3109                false,
3110                None,
3111                None,
3112                None,
3113                Some(BybitBboSideType::Queue),
3114                Some("2".to_string()),
3115                None,
3116            )
3117            .expect("Failed to build params");
3118
3119        assert_eq!(params.price, None);
3120        assert_eq!(params.bbo_side_type, Some(BybitBboSideType::Queue));
3121        assert_eq!(params.bbo_level.as_deref(), Some("2"));
3122    }
3123}