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