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    fmt::Debug,
22    sync::{
23        Arc,
24        atomic::{AtomicBool, AtomicU8, Ordering},
25    },
26    time::Duration,
27};
28
29use arc_swap::ArcSwap;
30use dashmap::DashMap;
31use nautilus_common::live::get_runtime;
32use nautilus_core::{AtomicMap, AtomicSet, UUID4, consts::NAUTILUS_USER_AGENT};
33use nautilus_model::{
34    data::BarType,
35    enums::{AggregationSource, OrderSide, OrderType, PriceType, TimeInForce, TriggerType},
36    identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
37    instruments::{Instrument, InstrumentAny},
38    types::{Price, Quantity},
39};
40use nautilus_network::{
41    backoff::ExponentialBackoff,
42    http::USER_AGENT,
43    mode::ConnectionMode,
44    websocket::{
45        AuthTracker, PingHandler, SubscriptionState, TransportBackend, WebSocketClient,
46        WebSocketConfig, channel_message_handler,
47    },
48};
49use serde_json::Value;
50use tokio_util::sync::CancellationToken;
51use ustr::Ustr;
52
53use crate::{
54    common::{
55        consts::{BYBIT_NAUTILUS_BROKER_ID, BYBIT_WS_TOPIC_DELIMITER},
56        credential::Credential,
57        enums::{
58            BybitBboSideType, BybitEnvironment, BybitOrderSide, BybitOrderType, BybitPositionIdx,
59            BybitProductType, BybitTimeInForce, BybitTpSlMode, BybitWsOrderRequestOp,
60            resolve_trigger_type,
61        },
62        parse::{
63            bar_spec_to_bybit_interval, extract_base_coin, extract_raw_symbol, map_time_in_force,
64            spot_leverage, spot_market_unit, trigger_direction,
65        },
66        symbol::BybitSymbol,
67        urls::{bybit_ws_private_url, bybit_ws_public_url, bybit_ws_trade_url},
68    },
69    websocket::{
70        dispatch::PendingOperation,
71        enums::{BybitWsOperation, BybitWsPrivateChannel, BybitWsPublicChannel},
72        error::{BybitWsError, BybitWsResult},
73        handler::{BybitWsFeedHandler, HandlerCommand},
74        messages::{
75            BybitAuthRequest, BybitSubscription, BybitWsAmendOrderParams, BybitWsBatchCancelItem,
76            BybitWsBatchCancelOrderArgs, BybitWsBatchPlaceItem, BybitWsBatchPlaceOrderArgs,
77            BybitWsCancelOrderParams, BybitWsHeader, BybitWsMessage, BybitWsPlaceOrderParams,
78            BybitWsRequest,
79        },
80    },
81};
82
83const WEBSOCKET_AUTH_WINDOW_MS: i64 = 5_000;
84const AUTH_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
85pub const BATCH_PROCESSING_LIMIT: usize = 20;
86
87/// Tracks a pending Python execution request for OrderResponse correlation.
88#[derive(Debug, Clone)]
89pub struct PendingPyRequest {
90    pub client_order_id: ClientOrderId,
91    pub operation: PendingOperation,
92    pub trader_id: TraderId,
93    pub strategy_id: StrategyId,
94    pub instrument_id: InstrumentId,
95    pub venue_order_id: Option<VenueOrderId>,
96}
97
98/// Public/market data WebSocket client for Bybit.
99#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
100#[cfg_attr(
101    feature = "python",
102    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
103)]
104pub struct BybitWebSocketClient {
105    url: String,
106    environment: BybitEnvironment,
107    product_type: Option<BybitProductType>,
108    credential: Option<Credential>,
109    requires_auth: bool,
110    auth_tracker: AuthTracker,
111    heartbeat: Option<u64>,
112    connection_mode: Arc<ArcSwap<AtomicU8>>,
113    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
114    out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<BybitWsMessage>>>,
115    signal: Arc<AtomicBool>,
116    task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
117    subscriptions: SubscriptionState,
118    account_id: Option<AccountId>,
119    mm_level: Arc<AtomicU8>,
120    bar_types_cache: Arc<AtomicMap<String, BarType>>,
121    instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
122    trade_subs: Arc<AtomicSet<InstrumentId>>,
123    option_greeks_subs: Arc<AtomicSet<InstrumentId>>,
124    bars_timestamp_on_close: Arc<AtomicBool>,
125    pending_py_requests: Arc<DashMap<String, Vec<PendingPyRequest>>>,
126    transport_backend: TransportBackend,
127    cancellation_token: CancellationToken,
128    proxy_url: Option<String>,
129}
130
131impl Debug for BybitWebSocketClient {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct(stringify!(BybitWebSocketClient))
134            .field("url", &self.url)
135            .field("environment", &self.environment)
136            .field("product_type", &self.product_type)
137            .field("requires_auth", &self.requires_auth)
138            .field("heartbeat", &self.heartbeat)
139            .field("confirmed_subscriptions", &self.subscriptions.len())
140            .finish()
141    }
142}
143
144impl Clone for BybitWebSocketClient {
145    fn clone(&self) -> Self {
146        Self {
147            url: self.url.clone(),
148            environment: self.environment,
149            product_type: self.product_type,
150            credential: self.credential.clone(),
151            requires_auth: self.requires_auth,
152            auth_tracker: self.auth_tracker.clone(),
153            heartbeat: self.heartbeat,
154            connection_mode: Arc::clone(&self.connection_mode),
155            cmd_tx: Arc::clone(&self.cmd_tx),
156            out_rx: None, // Each clone gets its own receiver
157            signal: Arc::clone(&self.signal),
158            task_handle: None, // Each clone gets its own task handle
159            subscriptions: self.subscriptions.clone(),
160            account_id: self.account_id,
161            mm_level: Arc::clone(&self.mm_level),
162            bar_types_cache: Arc::clone(&self.bar_types_cache),
163            instruments_cache: Arc::clone(&self.instruments_cache),
164            trade_subs: Arc::clone(&self.trade_subs),
165            option_greeks_subs: Arc::clone(&self.option_greeks_subs),
166            bars_timestamp_on_close: Arc::clone(&self.bars_timestamp_on_close),
167            pending_py_requests: Arc::clone(&self.pending_py_requests),
168            transport_backend: self.transport_backend,
169            cancellation_token: self.cancellation_token.clone(),
170            proxy_url: self.proxy_url.clone(),
171        }
172    }
173}
174
175impl BybitWebSocketClient {
176    /// Creates a new Bybit public WebSocket client.
177    #[must_use]
178    pub fn new_public(url: Option<String>, heartbeat: u64) -> Self {
179        Self::new_public_with(
180            BybitProductType::Linear,
181            BybitEnvironment::Mainnet,
182            url,
183            heartbeat,
184            TransportBackend::default(),
185            None,
186        )
187    }
188
189    /// Creates a new Bybit public WebSocket client targeting the specified product/environment.
190    #[must_use]
191    pub fn new_public_with(
192        product_type: BybitProductType,
193        environment: BybitEnvironment,
194        url: Option<String>,
195        heartbeat: u64,
196        transport_backend: TransportBackend,
197        proxy_url: Option<String>,
198    ) -> Self {
199        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
200
201        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
202        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
203
204        Self {
205            url: url.unwrap_or_else(|| bybit_ws_public_url(product_type, environment)),
206            environment,
207            product_type: Some(product_type),
208            credential: None,
209            requires_auth: false,
210            auth_tracker: AuthTracker::new(),
211            heartbeat: Some(heartbeat),
212            connection_mode,
213            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
214            out_rx: None,
215            signal: Arc::new(AtomicBool::new(false)),
216            task_handle: None,
217            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
218            bar_types_cache: Arc::new(AtomicMap::new()),
219            instruments_cache: Arc::new(AtomicMap::new()),
220            trade_subs: Arc::new(AtomicSet::new()),
221            option_greeks_subs: Arc::new(AtomicSet::new()),
222            bars_timestamp_on_close: Arc::new(AtomicBool::new(true)),
223            pending_py_requests: Arc::new(DashMap::new()),
224            account_id: None,
225            mm_level: Arc::new(AtomicU8::new(0)),
226            transport_backend,
227            cancellation_token: CancellationToken::new(),
228            proxy_url,
229        }
230    }
231
232    /// Creates a new Bybit private WebSocket client.
233    ///
234    /// If `api_key` or `api_secret` are not provided, they will be loaded from
235    /// environment variables based on the environment:
236    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
237    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
238    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
239    #[must_use]
240    pub fn new_private(
241        environment: BybitEnvironment,
242        api_key: Option<String>,
243        api_secret: Option<String>,
244        url: Option<String>,
245        heartbeat: u64,
246        transport_backend: TransportBackend,
247        proxy_url: Option<String>,
248    ) -> Self {
249        let credential = Credential::resolve(api_key, api_secret, environment);
250
251        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
252
253        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
254        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
255
256        Self {
257            url: url.unwrap_or_else(|| bybit_ws_private_url(environment).to_string()),
258            environment,
259            product_type: None,
260            credential,
261            requires_auth: true,
262            auth_tracker: AuthTracker::new(),
263            heartbeat: Some(heartbeat),
264            connection_mode,
265            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
266            out_rx: None,
267            signal: Arc::new(AtomicBool::new(false)),
268            task_handle: None,
269            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
270            bar_types_cache: Arc::new(AtomicMap::new()),
271            instruments_cache: Arc::new(AtomicMap::new()),
272            trade_subs: Arc::new(AtomicSet::new()),
273            option_greeks_subs: Arc::new(AtomicSet::new()),
274            bars_timestamp_on_close: Arc::new(AtomicBool::new(true)),
275            pending_py_requests: Arc::new(DashMap::new()),
276            account_id: None,
277            mm_level: Arc::new(AtomicU8::new(0)),
278            transport_backend,
279            cancellation_token: CancellationToken::new(),
280            proxy_url,
281        }
282    }
283
284    /// Creates a new Bybit trade WebSocket client for order operations.
285    ///
286    /// If `api_key` or `api_secret` are not provided, they will be loaded from
287    /// environment variables based on the environment:
288    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
289    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
290    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
291    #[must_use]
292    pub fn new_trade(
293        environment: BybitEnvironment,
294        api_key: Option<String>,
295        api_secret: Option<String>,
296        url: Option<String>,
297        heartbeat: u64,
298        transport_backend: TransportBackend,
299        proxy_url: Option<String>,
300    ) -> Self {
301        let credential = Credential::resolve(api_key, api_secret, environment);
302
303        let (cmd_tx, _) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
304
305        let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
306        let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
307
308        Self {
309            url: url.unwrap_or_else(|| bybit_ws_trade_url(environment).to_string()),
310            environment,
311            product_type: None,
312            credential,
313            requires_auth: true,
314            auth_tracker: AuthTracker::new(),
315            heartbeat: Some(heartbeat),
316            connection_mode,
317            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
318            out_rx: None,
319            signal: Arc::new(AtomicBool::new(false)),
320            task_handle: None,
321            subscriptions: SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER),
322            bar_types_cache: Arc::new(AtomicMap::new()),
323            instruments_cache: Arc::new(AtomicMap::new()),
324            trade_subs: Arc::new(AtomicSet::new()),
325            option_greeks_subs: Arc::new(AtomicSet::new()),
326            bars_timestamp_on_close: Arc::new(AtomicBool::new(true)),
327            pending_py_requests: Arc::new(DashMap::new()),
328            account_id: None,
329            mm_level: Arc::new(AtomicU8::new(0)),
330            transport_backend,
331            cancellation_token: CancellationToken::new(),
332            proxy_url,
333        }
334    }
335
336    /// Establishes the WebSocket connection.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the underlying WebSocket connection cannot be established,
341    /// after retrying multiple times with exponential backoff.
342    pub async fn connect(&mut self) -> BybitWsResult<()> {
343        const MAX_RETRIES: u32 = 5;
344        const CONNECTION_TIMEOUT_SECS: u64 = 10;
345
346        self.signal.store(false, Ordering::Relaxed);
347
348        let (raw_handler, raw_rx) = channel_message_handler();
349
350        // No-op ping handler: handler owns the WebSocketClient and responds to pings directly
351        // in the message loop for minimal latency (see handler.rs pong response)
352        let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {
353            // Handler responds to pings internally via select! loop
354        });
355
356        let ping_msg = serde_json::to_string(&BybitSubscription {
357            op: BybitWsOperation::Ping,
358            args: vec![],
359            req_id: None,
360        })?;
361
362        let config = WebSocketConfig {
363            url: self.url.clone(),
364            headers: Self::default_headers(),
365            heartbeat: self.heartbeat,
366            heartbeat_msg: Some(ping_msg),
367            reconnect_timeout_ms: Some(5_000),
368            reconnect_delay_initial_ms: Some(500),
369            reconnect_delay_max_ms: Some(5_000),
370            reconnect_backoff_factor: Some(1.5),
371            reconnect_jitter_ms: Some(250),
372            reconnect_max_attempts: None,
373            idle_timeout_ms: None,
374            backend: self.transport_backend,
375            proxy_url: self.proxy_url.clone(),
376        };
377
378        // Retry initial connection with exponential backoff to handle transient DNS/network issues
379        let mut backoff = ExponentialBackoff::new(
380            Duration::from_millis(500),
381            Duration::from_millis(5000),
382            2.0,
383            250,
384            false,
385        )
386        .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
387
388        #[allow(unused_assignments)]
389        let mut last_error = String::new();
390        let mut attempt = 0;
391        let client = loop {
392            attempt += 1;
393
394            match tokio::time::timeout(
395                Duration::from_secs(CONNECTION_TIMEOUT_SECS),
396                WebSocketClient::connect(
397                    config.clone(),
398                    Some(raw_handler.clone()),
399                    Some(ping_handler.clone()),
400                    None,
401                    vec![],
402                    None,
403                ),
404            )
405            .await
406            {
407                Ok(Ok(client)) => {
408                    if attempt > 1 {
409                        log::info!("WebSocket connection established after {attempt} attempts");
410                    }
411                    break client;
412                }
413                Ok(Err(e)) => {
414                    last_error = e.to_string();
415                    log::warn!(
416                        "WebSocket connection attempt failed: attempt={attempt}, max_retries={MAX_RETRIES}, url={}, error={last_error}",
417                        self.url
418                    );
419                }
420                Err(_) => {
421                    last_error = format!(
422                        "Connection timeout after {CONNECTION_TIMEOUT_SECS}s (possible DNS resolution failure)"
423                    );
424                    log::warn!(
425                        "WebSocket connection attempt timed out: attempt={attempt}, max_retries={MAX_RETRIES}, url={}",
426                        self.url
427                    );
428                }
429            }
430
431            if attempt >= MAX_RETRIES {
432                return Err(BybitWsError::Transport(format!(
433                    "Failed to connect to {} after {MAX_RETRIES} attempts: {}. \
434                    If this is a DNS error, check your network configuration and DNS settings.",
435                    self.url,
436                    if last_error.is_empty() {
437                        "unknown error"
438                    } else {
439                        &last_error
440                    }
441                )));
442            }
443
444            let delay = backoff.next_duration();
445            log::debug!(
446                "Retrying in {delay:?} (attempt {}/{MAX_RETRIES})",
447                attempt + 1
448            );
449            tokio::time::sleep(delay).await;
450        };
451
452        self.connection_mode.store(client.connection_mode_atomic());
453        client.set_auth_tracker(self.auth_tracker.clone(), self.requires_auth);
454
455        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<BybitWsMessage>();
456        self.out_rx = Some(Arc::new(out_rx));
457
458        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
459        *self.cmd_tx.write().await = cmd_tx.clone();
460
461        let cmd = HandlerCommand::SetClient(client);
462
463        self.send_cmd(cmd).await?;
464
465        let signal = Arc::clone(&self.signal);
466        let subscriptions = self.subscriptions.clone();
467        let credential = self.credential.clone();
468        let requires_auth = self.requires_auth;
469        let cmd_tx_for_reconnect = cmd_tx.clone();
470        let auth_tracker = self.auth_tracker.clone();
471        let auth_tracker_for_handler = auth_tracker.clone();
472
473        let stream_handle = get_runtime().spawn(async move {
474            let mut handler = BybitWsFeedHandler::new(
475                signal.clone(),
476                cmd_rx,
477                raw_rx,
478                auth_tracker_for_handler,
479                subscriptions.clone(),
480            );
481
482            // Helper closure to resubscribe all tracked subscriptions after reconnection
483            let resubscribe_all = || async {
484                let topics = subscriptions.all_topics();
485
486                if topics.is_empty() {
487                    return;
488                }
489
490                log::debug!(
491                    "Resubscribing to confirmed subscriptions: count={}",
492                    topics.len()
493                );
494
495                for topic in &topics {
496                    subscriptions.mark_subscribe(topic.as_str());
497                }
498
499                let mut payloads = Vec::with_capacity(topics.len());
500                for topic in &topics {
501                    let message = BybitSubscription {
502                        op: BybitWsOperation::Subscribe,
503                        args: vec![topic.clone()],
504                        req_id: Some(topic.clone()),
505                    };
506
507                    if let Ok(payload) = serde_json::to_string(&message) {
508                        payloads.push(payload);
509                    }
510                }
511
512                let cmd = HandlerCommand::Subscribe { topics: payloads };
513
514                if let Err(e) = cmd_tx_for_reconnect.send(cmd) {
515                    log::error!("Failed to send resubscribe command: {e}");
516                }
517            };
518
519            // Run message processing with reconnection handling
520            loop {
521                match handler.next().await {
522                    Some(BybitWsMessage::Reconnected) => {
523                        if signal.load(Ordering::Relaxed) {
524                            continue;
525                        }
526
527                        log::info!("WebSocket reconnected");
528
529                        // Mark all confirmed subscriptions as failed so they transition to pending state
530                        let confirmed_topics: Vec<String> = {
531                            let confirmed = subscriptions.confirmed();
532                            let mut topics = Vec::new();
533
534                            for entry in confirmed.iter() {
535                                let (channel, symbols) = entry.pair();
536                                for symbol in symbols {
537                                    if symbol.is_empty() {
538                                        topics.push(channel.to_string());
539                                    } else {
540                                        topics.push(format!("{channel}.{symbol}"));
541                                    }
542                                }
543                            }
544                            topics
545                        };
546
547                        if !confirmed_topics.is_empty() {
548                            log::debug!(
549                                "Marking confirmed subscriptions as pending for replay: count={}",
550                                confirmed_topics.len()
551                            );
552
553                            for topic in confirmed_topics {
554                                subscriptions.mark_failure(&topic);
555                            }
556                        }
557
558                        if requires_auth {
559                            log::debug!("Re-authenticating after reconnection");
560
561                            if let Some(cred) = &credential {
562                                // Begin auth attempt so succeed() will update state
563                                let _rx = auth_tracker.begin();
564
565                                let expires = chrono::Utc::now().timestamp_millis()
566                                    + WEBSOCKET_AUTH_WINDOW_MS;
567                                let signature = cred.sign_websocket_auth(expires);
568
569                                let auth_message = BybitAuthRequest {
570                                    op: BybitWsOperation::Auth,
571                                    args: vec![
572                                        Value::String(cred.api_key().to_string()),
573                                        Value::Number(expires.into()),
574                                        Value::String(signature),
575                                    ],
576                                };
577
578                                if let Ok(payload) = serde_json::to_string(&auth_message) {
579                                    let cmd = HandlerCommand::Authenticate { payload };
580                                    if let Err(e) = cmd_tx_for_reconnect.send(cmd) {
581                                        log::error!(
582                                            "Failed to send reconnection auth command: error={e}"
583                                        );
584                                    }
585                                } else {
586                                    log::error!("Failed to serialize reconnection auth message");
587                                }
588                            }
589                        }
590
591                        // Unauthenticated sessions resubscribe immediately after reconnection,
592                        // authenticated sessions wait for Auth message
593                        if !requires_auth {
594                            log::debug!("No authentication required, resubscribing immediately");
595                            resubscribe_all().await;
596                        }
597
598                        // Forward to out_tx so caller sees the Reconnected message
599                        if out_tx.send(BybitWsMessage::Reconnected).is_err() {
600                            if handler.is_stopped() {
601                                log::debug!("Receiver dropped, stopping");
602                            } else {
603                                log::error!("Receiver dropped, stopping");
604                            }
605                            break;
606                        }
607                    }
608                    Some(BybitWsMessage::Auth(ref auth)) => {
609                        let is_success = auth.success.unwrap_or(false) || auth.ret_code == Some(0);
610                        if is_success {
611                            log::debug!("Authenticated, resubscribing");
612                            resubscribe_all().await;
613                        }
614
615                        if out_tx.send(BybitWsMessage::Auth(auth.clone())).is_err() {
616                            if handler.is_stopped() {
617                                log::debug!("Failed to send message (receiver dropped)");
618                            } else {
619                                log::error!("Failed to send message (receiver dropped)");
620                            }
621                            break;
622                        }
623                    }
624                    Some(msg) => {
625                        if out_tx.send(msg).is_err() {
626                            if handler.is_stopped() {
627                                log::debug!("Failed to send message (receiver dropped)");
628                            } else {
629                                log::error!("Failed to send message (receiver dropped)");
630                            }
631                            break;
632                        }
633                    }
634                    None => {
635                        // Stream ended - check if it's a stop signal
636                        if handler.is_stopped() {
637                            log::debug!("Stop signal received, ending message processing");
638                            break;
639                        }
640                        // Otherwise it's an unexpected stream end
641                        log::warn!("WebSocket stream ended unexpectedly");
642                        break;
643                    }
644                }
645            }
646
647            log::debug!("Handler task exiting");
648        });
649
650        self.task_handle = Some(Arc::new(stream_handle));
651
652        if requires_auth && let Err(e) = self.authenticate_if_required().await {
653            return Err(e);
654        }
655
656        Ok(())
657    }
658
659    /// Disconnects the WebSocket client and stops the background task.
660    pub async fn close(&mut self) -> BybitWsResult<()> {
661        log::debug!("Starting close process");
662
663        self.signal.store(true, Ordering::Relaxed);
664
665        let cmd = HandlerCommand::Disconnect;
666        if let Err(e) = self.cmd_tx.read().await.send(cmd) {
667            log::debug!(
668                "Failed to send disconnect command (handler may already be shut down): {e}"
669            );
670        }
671
672        if let Some(task_handle) = self.task_handle.take() {
673            match Arc::try_unwrap(task_handle) {
674                Ok(handle) => {
675                    log::debug!("Waiting for task handle to complete");
676                    match tokio::time::timeout(Duration::from_secs(2), handle).await {
677                        Ok(Ok(())) => log::debug!("Task handle completed successfully"),
678                        Ok(Err(e)) => log::error!("Task handle encountered an error: {e:?}"),
679                        Err(_) => {
680                            log::warn!(
681                                "Timeout waiting for task handle, task may still be running"
682                            );
683                        }
684                    }
685                }
686                Err(arc_handle) => {
687                    log::debug!(
688                        "Cannot take ownership of task handle - other references exist, aborting task"
689                    );
690                    arc_handle.abort();
691                }
692            }
693        } else {
694            log::debug!("No task handle to await");
695        }
696
697        self.auth_tracker.invalidate();
698
699        log::debug!("Closed");
700
701        Ok(())
702    }
703
704    /// Returns a value indicating whether the client is active.
705    #[must_use]
706    pub fn is_active(&self) -> bool {
707        let connection_mode_arc = self.connection_mode.load();
708        ConnectionMode::from_atomic(&connection_mode_arc).is_active()
709            && !self.signal.load(Ordering::Relaxed)
710    }
711
712    /// Returns a value indicating whether the client is closed.
713    pub fn is_closed(&self) -> bool {
714        let connection_mode_arc = self.connection_mode.load();
715        ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
716            || self.signal.load(Ordering::Relaxed)
717    }
718
719    /// Waits until the WebSocket client becomes active or times out.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if the timeout is exceeded before the client becomes active.
724    pub async fn wait_until_active(&self, timeout_secs: f64) -> BybitWsResult<()> {
725        let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
726
727        tokio::time::timeout(timeout, async {
728            while !self.is_active() {
729                tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
730            }
731        })
732        .await
733        .map_err(|_| {
734            BybitWsError::ClientError(format!(
735                "WebSocket connection timeout after {timeout_secs} seconds"
736            ))
737        })?;
738
739        Ok(())
740    }
741
742    /// Subscribe to the provided topic strings.
743    pub async fn subscribe(&self, topics: Vec<String>) -> BybitWsResult<()> {
744        if topics.is_empty() {
745            return Ok(());
746        }
747
748        log::debug!("Subscribing to topics: {topics:?}");
749
750        // Use reference counting to deduplicate subscriptions
751        let mut topics_to_send = Vec::new();
752
753        for topic in topics {
754            // Returns true if this is the first subscription (ref count 0 -> 1)
755            if self.subscriptions.add_reference(&topic) {
756                self.subscriptions.mark_subscribe(&topic);
757                topics_to_send.push(topic.clone());
758            } else {
759                log::debug!("Already subscribed to {topic}, skipping duplicate subscription");
760            }
761        }
762
763        if topics_to_send.is_empty() {
764            return Ok(());
765        }
766
767        // Serialize subscription messages
768        let mut payloads = Vec::with_capacity(topics_to_send.len());
769        for topic in &topics_to_send {
770            let message = BybitSubscription {
771                op: BybitWsOperation::Subscribe,
772                args: vec![topic.clone()],
773                req_id: Some(topic.clone()),
774            };
775            let payload = serde_json::to_string(&message).map_err(|e| {
776                BybitWsError::Json(format!("Failed to serialize subscription: {e}"))
777            })?;
778            payloads.push(payload);
779        }
780
781        let cmd = HandlerCommand::Subscribe { topics: payloads };
782        self.cmd_tx
783            .read()
784            .await
785            .send(cmd)
786            .map_err(|e| BybitWsError::Send(format!("Failed to send subscribe command: {e}")))?;
787
788        Ok(())
789    }
790
791    /// Unsubscribe from the provided topics.
792    pub async fn unsubscribe(&self, topics: Vec<String>) -> BybitWsResult<()> {
793        if topics.is_empty() {
794            return Ok(());
795        }
796
797        log::debug!("Attempting to unsubscribe from topics: {topics:?}");
798
799        if self.signal.load(Ordering::Relaxed) {
800            log::debug!("Shutdown signal detected, skipping unsubscribe");
801            return Ok(());
802        }
803
804        // Use reference counting to avoid unsubscribing while other consumers still need the topic
805        let mut topics_to_send = Vec::new();
806
807        for topic in topics {
808            // Returns true if this was the last subscription (ref count 1 -> 0)
809            if self.subscriptions.remove_reference(&topic) {
810                self.subscriptions.mark_unsubscribe(&topic);
811                topics_to_send.push(topic.clone());
812            } else {
813                log::debug!("Topic {topic} still has active subscriptions, not unsubscribing");
814            }
815        }
816
817        if topics_to_send.is_empty() {
818            return Ok(());
819        }
820
821        // Serialize unsubscription messages
822        let mut payloads = Vec::with_capacity(topics_to_send.len());
823        for topic in &topics_to_send {
824            let message = BybitSubscription {
825                op: BybitWsOperation::Unsubscribe,
826                args: vec![topic.clone()],
827                req_id: Some(topic.clone()),
828            };
829
830            if let Ok(payload) = serde_json::to_string(&message) {
831                payloads.push(payload);
832            }
833        }
834
835        let cmd = HandlerCommand::Unsubscribe { topics: payloads };
836        if let Err(e) = self.cmd_tx.read().await.send(cmd) {
837            log::debug!("Failed to send unsubscribe command: error={e}");
838        }
839
840        Ok(())
841    }
842
843    /// Returns a stream of venue-typed [`BybitWsMessage`] items.
844    ///
845    /// # Panics
846    ///
847    /// Panics if called before [`Self::connect`] or if the stream has already been taken.
848    pub fn stream(&mut self) -> impl futures_util::Stream<Item = BybitWsMessage> + use<> {
849        let rx = self
850            .out_rx
851            .take()
852            .expect("Stream receiver already taken or client not connected");
853        let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
854        async_stream::stream! {
855            while let Some(msg) = rx.recv().await {
856                yield msg;
857            }
858        }
859    }
860
861    /// Returns the number of currently registered subscriptions.
862    #[must_use]
863    pub fn subscription_count(&self) -> usize {
864        self.subscriptions.len()
865    }
866
867    /// Returns the credential associated with this client, if any.
868    #[must_use]
869    pub fn credential(&self) -> Option<&Credential> {
870        self.credential.as_ref()
871    }
872
873    /// Sets the account ID for account message parsing.
874    pub fn set_account_id(&mut self, account_id: AccountId) {
875        self.account_id = Some(account_id);
876    }
877
878    /// Sets the account market maker level.
879    pub fn set_mm_level(&self, mm_level: u8) {
880        self.mm_level.store(mm_level, Ordering::Relaxed);
881    }
882
883    /// Returns the account ID if set.
884    #[must_use]
885    pub fn account_id(&self) -> Option<AccountId> {
886        self.account_id
887    }
888
889    /// Returns the product type for public connections.
890    #[must_use]
891    pub fn product_type(&self) -> Option<BybitProductType> {
892        self.product_type
893    }
894
895    /// Returns a reference to the bar types cache.
896    #[must_use]
897    pub fn bar_types_cache(&self) -> &Arc<AtomicMap<String, BarType>> {
898        &self.bar_types_cache
899    }
900
901    /// Adds an instrument to the shared instruments cache.
902    pub fn cache_instrument(&self, instrument: InstrumentAny) {
903        self.instruments_cache
904            .insert(instrument.id().symbol.inner(), instrument);
905    }
906
907    /// Returns a snapshot of the instruments cache keyed by symbol.
908    #[must_use]
909    pub fn instruments_snapshot(&self) -> ahash::AHashMap<Ustr, InstrumentAny> {
910        (**self.instruments_cache.load()).clone()
911    }
912
913    /// Sets whether bar timestamps use the close time.
914    pub fn set_bars_timestamp_on_close(&self, value: bool) {
915        self.bars_timestamp_on_close.store(value, Ordering::Relaxed);
916    }
917
918    /// Returns whether bar timestamps use the close time.
919    #[must_use]
920    pub fn bars_timestamp_on_close(&self) -> bool {
921        self.bars_timestamp_on_close.load(Ordering::Relaxed)
922    }
923
924    /// Adds an instrument ID to the option greeks subscription set.
925    pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
926        self.option_greeks_subs.insert(instrument_id);
927    }
928
929    /// Removes an instrument ID from the option greeks subscription set.
930    pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
931        self.option_greeks_subs.remove(instrument_id);
932    }
933
934    /// Returns a reference to the option greeks subscription set.
935    #[must_use]
936    pub fn option_greeks_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
937        &self.option_greeks_subs
938    }
939
940    /// Returns a reference to the trade subscriptions set.
941    #[must_use]
942    pub fn trade_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
943        &self.trade_subs
944    }
945
946    /// Returns a reference to the pending Python requests map.
947    #[must_use]
948    pub fn pending_py_requests(&self) -> &Arc<DashMap<String, Vec<PendingPyRequest>>> {
949        &self.pending_py_requests
950    }
951
952    /// Returns a reference to the live instruments cache Arc.
953    #[must_use]
954    pub fn instruments_cache_ref(&self) -> &Arc<AtomicMap<Ustr, InstrumentAny>> {
955        &self.instruments_cache
956    }
957
958    /// Subscribes to orderbook updates for a specific instrument.
959    ///
960    /// # Errors
961    ///
962    /// Returns an error if the subscription request fails.
963    ///
964    /// # References
965    ///
966    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>
967    pub async fn subscribe_orderbook(
968        &self,
969        instrument_id: InstrumentId,
970        depth: u32,
971    ) -> BybitWsResult<()> {
972        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
973        let topic = format!(
974            "{}.{depth}.{raw_symbol}",
975            BybitWsPublicChannel::OrderBook.as_ref()
976        );
977        self.subscribe(vec![topic]).await
978    }
979
980    /// Unsubscribes from orderbook updates for a specific instrument.
981    pub async fn unsubscribe_orderbook(
982        &self,
983        instrument_id: InstrumentId,
984        depth: u32,
985    ) -> BybitWsResult<()> {
986        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
987        let topic = format!(
988            "{}.{depth}.{raw_symbol}",
989            BybitWsPublicChannel::OrderBook.as_ref()
990        );
991        self.unsubscribe(vec![topic]).await
992    }
993
994    /// Subscribes to public trade updates for a specific instrument.
995    ///
996    /// # Errors
997    ///
998    /// Returns an error if the subscription request fails.
999    ///
1000    /// # References
1001    ///
1002    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/trade>
1003    pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1004        self.trade_subs.insert(instrument_id);
1005        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1006        // Bybit option trades use baseCoin topic (e.g. publicTrade.BTC)
1007        let topic_symbol = match self.product_type {
1008            Some(BybitProductType::Option) => extract_base_coin(raw_symbol),
1009            _ => raw_symbol,
1010        };
1011        let topic = format!(
1012            "{}.{topic_symbol}",
1013            BybitWsPublicChannel::PublicTrade.as_ref()
1014        );
1015        self.subscribe(vec![topic]).await
1016    }
1017
1018    /// Unsubscribes from public trade updates for a specific instrument.
1019    pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1020        self.trade_subs.remove(&instrument_id);
1021        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1022        let topic_symbol = match self.product_type {
1023            Some(BybitProductType::Option) => extract_base_coin(raw_symbol),
1024            _ => raw_symbol,
1025        };
1026        let topic = format!(
1027            "{}.{topic_symbol}",
1028            BybitWsPublicChannel::PublicTrade.as_ref()
1029        );
1030        self.unsubscribe(vec![topic]).await
1031    }
1032
1033    /// Subscribes to ticker updates for a specific instrument.
1034    ///
1035    /// # Errors
1036    ///
1037    /// Returns an error if the subscription request fails.
1038    ///
1039    /// # References
1040    ///
1041    /// <https://bybit-exchange.github.io/docs/v5/websocket/public/ticker>
1042    pub async fn subscribe_ticker(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1043        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1044        let topic = format!("{}.{raw_symbol}", BybitWsPublicChannel::Tickers.as_ref());
1045        self.subscribe(vec![topic]).await
1046    }
1047
1048    /// Unsubscribes from ticker updates for a specific instrument.
1049    pub async fn unsubscribe_ticker(&self, instrument_id: InstrumentId) -> BybitWsResult<()> {
1050        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1051        let topic = format!("{}.{raw_symbol}", BybitWsPublicChannel::Tickers.as_ref());
1052        self.unsubscribe(vec![topic]).await
1053    }
1054
1055    /// Subscribes to kline/candlestick 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/kline>
1064    pub async fn subscribe_bars(&self, bar_type: BarType) -> BybitWsResult<()> {
1065        if self.product_type == Some(BybitProductType::Option) {
1066            return Err(BybitWsError::ClientError(
1067                "Bybit does not support kline/bar data for options".to_string(),
1068            ));
1069        }
1070
1071        let spec = bar_type.spec();
1072
1073        if spec.price_type != PriceType::Last {
1074            return Err(BybitWsError::ClientError(format!(
1075                "Invalid bar type: Bybit bars only support LAST price type, received {}",
1076                spec.price_type
1077            )));
1078        }
1079
1080        if bar_type.aggregation_source() != AggregationSource::External {
1081            return Err(BybitWsError::ClientError(format!(
1082                "Invalid bar type: Bybit bars only support EXTERNAL aggregation source, received {}",
1083                bar_type.aggregation_source()
1084            )));
1085        }
1086
1087        let interval = bar_spec_to_bybit_interval(spec.aggregation, spec.step.get() as u64)
1088            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1089
1090        let instrument_id = bar_type.instrument_id();
1091        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1092        let topic = format!(
1093            "{}.{}.{raw_symbol}",
1094            BybitWsPublicChannel::Kline.as_ref(),
1095            interval
1096        );
1097
1098        // Coordinate with reference counting to avoid duplicate cache entries
1099        if self.subscriptions.get_reference_count(&topic) == 0 {
1100            self.bar_types_cache.insert(topic.clone(), bar_type);
1101        }
1102
1103        self.subscribe(vec![topic]).await
1104    }
1105
1106    /// Unsubscribes from kline/candlestick updates for a specific instrument.
1107    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> BybitWsResult<()> {
1108        let spec = bar_type.spec();
1109        let interval = bar_spec_to_bybit_interval(spec.aggregation, spec.step.get() as u64)
1110            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1111
1112        let instrument_id = bar_type.instrument_id();
1113        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1114        let topic = format!(
1115            "{}.{}.{raw_symbol}",
1116            BybitWsPublicChannel::Kline.as_ref(),
1117            interval
1118        );
1119
1120        // Coordinate with reference counting to preserve cache for other subscribers
1121        if self.subscriptions.get_reference_count(&topic) == 1 {
1122            self.bar_types_cache.remove(&topic);
1123        }
1124
1125        self.unsubscribe(vec![topic]).await
1126    }
1127
1128    /// Subscribes to order updates.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns an error if the subscription request fails or if not authenticated.
1133    ///
1134    /// # References
1135    ///
1136    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/order>
1137    pub async fn subscribe_orders(&self) -> BybitWsResult<()> {
1138        if !self.requires_auth {
1139            return Err(BybitWsError::Authentication(
1140                "Order subscription requires authentication".to_string(),
1141            ));
1142        }
1143        self.subscribe(vec![BybitWsPrivateChannel::Order.as_ref().to_string()])
1144            .await
1145    }
1146
1147    /// Unsubscribes from order updates.
1148    pub async fn unsubscribe_orders(&self) -> BybitWsResult<()> {
1149        self.unsubscribe(vec![BybitWsPrivateChannel::Order.as_ref().to_string()])
1150            .await
1151    }
1152
1153    /// Subscribes to execution/fill updates.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error if the subscription request fails or if not authenticated.
1158    ///
1159    /// # References
1160    ///
1161    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/execution>
1162    pub async fn subscribe_executions(&self) -> BybitWsResult<()> {
1163        if !self.requires_auth {
1164            return Err(BybitWsError::Authentication(
1165                "Execution subscription requires authentication".to_string(),
1166            ));
1167        }
1168        self.subscribe(vec![BybitWsPrivateChannel::Execution.as_ref().to_string()])
1169            .await
1170    }
1171
1172    /// Unsubscribes from execution/fill updates.
1173    pub async fn unsubscribe_executions(&self) -> BybitWsResult<()> {
1174        self.unsubscribe(vec![BybitWsPrivateChannel::Execution.as_ref().to_string()])
1175            .await
1176    }
1177
1178    /// Subscribes to fast execution updates (slim payload, lower latency).
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns an error if the subscription request fails or if not authenticated.
1183    ///
1184    /// # References
1185    ///
1186    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/fast-execution>
1187    pub async fn subscribe_executions_fast(&self) -> BybitWsResult<()> {
1188        if !self.requires_auth {
1189            return Err(BybitWsError::Authentication(
1190                "Fast execution subscription requires authentication".to_string(),
1191            ));
1192        }
1193        self.subscribe(vec![
1194            BybitWsPrivateChannel::ExecutionFast.as_ref().to_string(),
1195        ])
1196        .await
1197    }
1198
1199    /// Unsubscribes from fast execution updates.
1200    pub async fn unsubscribe_executions_fast(&self) -> BybitWsResult<()> {
1201        self.unsubscribe(vec![
1202            BybitWsPrivateChannel::ExecutionFast.as_ref().to_string(),
1203        ])
1204        .await
1205    }
1206
1207    /// Subscribes to position updates.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns an error if the subscription request fails or if not authenticated.
1212    ///
1213    /// # References
1214    ///
1215    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/position>
1216    pub async fn subscribe_positions(&self) -> BybitWsResult<()> {
1217        if !self.requires_auth {
1218            return Err(BybitWsError::Authentication(
1219                "Position subscription requires authentication".to_string(),
1220            ));
1221        }
1222        self.subscribe(vec![BybitWsPrivateChannel::Position.as_ref().to_string()])
1223            .await
1224    }
1225
1226    /// Unsubscribes from position updates.
1227    pub async fn unsubscribe_positions(&self) -> BybitWsResult<()> {
1228        self.unsubscribe(vec![BybitWsPrivateChannel::Position.as_ref().to_string()])
1229            .await
1230    }
1231
1232    /// Subscribes to wallet/balance updates.
1233    ///
1234    /// # Errors
1235    ///
1236    /// Returns an error if the subscription request fails or if not authenticated.
1237    ///
1238    /// # References
1239    ///
1240    /// <https://bybit-exchange.github.io/docs/v5/websocket/private/wallet>
1241    pub async fn subscribe_wallet(&self) -> BybitWsResult<()> {
1242        if !self.requires_auth {
1243            return Err(BybitWsError::Authentication(
1244                "Wallet subscription requires authentication".to_string(),
1245            ));
1246        }
1247        self.subscribe(vec![BybitWsPrivateChannel::Wallet.as_ref().to_string()])
1248            .await
1249    }
1250
1251    /// Unsubscribes from wallet/balance updates.
1252    pub async fn unsubscribe_wallet(&self) -> BybitWsResult<()> {
1253        self.unsubscribe(vec![BybitWsPrivateChannel::Wallet.as_ref().to_string()])
1254            .await
1255    }
1256
1257    /// Waits for the session to be authenticated, aborting early if the client
1258    /// enters a terminal state (closed or disconnecting) during the wait.
1259    async fn require_authenticated(&self) -> BybitWsResult<()> {
1260        if self.is_closed() {
1261            return Err(BybitWsError::ClientError(
1262                "WebSocket client is closed".to_string(),
1263            ));
1264        }
1265
1266        if self.auth_tracker.is_authenticated() {
1267            return Ok(());
1268        }
1269
1270        tokio::select! {
1271            authenticated = self.auth_tracker.wait_for_authenticated(AUTH_WAIT_TIMEOUT) => {
1272                if authenticated {
1273                    Ok(())
1274                } else {
1275                    Err(BybitWsError::Authentication(
1276                        "Must be authenticated".to_string(),
1277                    ))
1278                }
1279            }
1280            () = async {
1281                loop {
1282                    tokio::time::sleep(Duration::from_millis(100)).await;
1283
1284                    if self.is_closed() {
1285                        return;
1286                    }
1287                }
1288            } => {
1289                Err(BybitWsError::ClientError(
1290                    "WebSocket client closed during authentication wait".to_string(),
1291                ))
1292            }
1293        }
1294    }
1295
1296    /// Places an order via WebSocket, returning the request ID for correlation.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns an error if the order request fails or if not authenticated.
1301    pub async fn place_order(&self, params: BybitWsPlaceOrderParams) -> BybitWsResult<String> {
1302        self.require_authenticated().await?;
1303
1304        let req_id = UUID4::new().to_string();
1305
1306        let referer = if self.include_referer_header(params.time_in_force) {
1307            Some(BYBIT_NAUTILUS_BROKER_ID.to_string())
1308        } else {
1309            None
1310        };
1311
1312        let request = BybitWsRequest {
1313            req_id: Some(req_id.clone()),
1314            op: BybitWsOrderRequestOp::Create,
1315            header: BybitWsHeader::with_referer(referer),
1316            args: vec![params],
1317        };
1318
1319        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1320        self.send_text(&payload).await?;
1321
1322        Ok(req_id)
1323    }
1324
1325    /// Amends an existing order via WebSocket, returning the request ID for correlation.
1326    ///
1327    /// # Errors
1328    ///
1329    /// Returns an error if the amend request fails or if not authenticated.
1330    pub async fn amend_order(&self, params: BybitWsAmendOrderParams) -> BybitWsResult<String> {
1331        self.require_authenticated().await?;
1332
1333        let req_id = UUID4::new().to_string();
1334
1335        let request = BybitWsRequest {
1336            req_id: Some(req_id.clone()),
1337            op: BybitWsOrderRequestOp::Amend,
1338            header: BybitWsHeader::now(),
1339            args: vec![params],
1340        };
1341
1342        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1343        self.send_text(&payload).await?;
1344
1345        Ok(req_id)
1346    }
1347
1348    /// Cancels an order via WebSocket, returning the request ID for correlation.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns an error if the cancel request fails or if not authenticated.
1353    pub async fn cancel_order(&self, params: BybitWsCancelOrderParams) -> BybitWsResult<String> {
1354        self.require_authenticated().await?;
1355
1356        let req_id = UUID4::new().to_string();
1357
1358        let request = BybitWsRequest {
1359            req_id: Some(req_id.clone()),
1360            op: BybitWsOrderRequestOp::Cancel,
1361            header: BybitWsHeader::now(),
1362            args: vec![params],
1363        };
1364
1365        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1366        self.send_text(&payload).await?;
1367
1368        Ok(req_id)
1369    }
1370
1371    /// Batch creates multiple orders via WebSocket, returning the request ID for correlation.
1372    ///
1373    /// # Errors
1374    ///
1375    /// Returns an error if the batch request fails or if not authenticated.
1376    pub async fn batch_place_orders(
1377        &self,
1378        orders: Vec<BybitWsPlaceOrderParams>,
1379    ) -> BybitWsResult<Vec<String>> {
1380        self.require_authenticated().await?;
1381
1382        if orders.is_empty() {
1383            log::warn!("Batch place orders called with empty orders list");
1384            return Ok(vec![]);
1385        }
1386
1387        let mut req_ids = Vec::new();
1388
1389        for chunk in orders.chunks(BATCH_PROCESSING_LIMIT) {
1390            let req_id = self.batch_place_orders_chunk(chunk.to_vec()).await?;
1391            req_ids.push(req_id);
1392        }
1393
1394        Ok(req_ids)
1395    }
1396
1397    async fn batch_place_orders_chunk(
1398        &self,
1399        orders: Vec<BybitWsPlaceOrderParams>,
1400    ) -> BybitWsResult<String> {
1401        let category = orders[0].category;
1402        let batch_req_id = UUID4::new().to_string();
1403
1404        let mm_level = self.mm_level.load(Ordering::Relaxed);
1405        let has_non_post_only = orders
1406            .iter()
1407            .any(|o| !matches!(o.time_in_force, Some(BybitTimeInForce::PostOnly)));
1408        let referer = if has_non_post_only || mm_level == 0 {
1409            Some(BYBIT_NAUTILUS_BROKER_ID.to_string())
1410        } else {
1411            None
1412        };
1413
1414        let request_items: Vec<BybitWsBatchPlaceItem> = orders
1415            .into_iter()
1416            .map(|order| BybitWsBatchPlaceItem {
1417                symbol: order.symbol,
1418                side: order.side,
1419                order_type: order.order_type,
1420                qty: order.qty,
1421                is_leverage: order.is_leverage,
1422                market_unit: order.market_unit,
1423                price: order.price,
1424                time_in_force: order.time_in_force,
1425                order_link_id: order.order_link_id,
1426                reduce_only: order.reduce_only,
1427                close_on_trigger: order.close_on_trigger,
1428                trigger_price: order.trigger_price,
1429                trigger_by: order.trigger_by,
1430                trigger_direction: order.trigger_direction,
1431                tpsl_mode: order.tpsl_mode,
1432                take_profit: order.take_profit,
1433                stop_loss: order.stop_loss,
1434                tp_trigger_by: order.tp_trigger_by,
1435                sl_trigger_by: order.sl_trigger_by,
1436                sl_trigger_price: order.sl_trigger_price,
1437                tp_trigger_price: order.tp_trigger_price,
1438                sl_order_type: order.sl_order_type,
1439                tp_order_type: order.tp_order_type,
1440                sl_limit_price: order.sl_limit_price,
1441                tp_limit_price: order.tp_limit_price,
1442                order_iv: order.order_iv,
1443                mmp: order.mmp,
1444                position_idx: order.position_idx,
1445                bbo_side_type: order.bbo_side_type,
1446                bbo_level: order.bbo_level,
1447            })
1448            .collect();
1449
1450        let args = BybitWsBatchPlaceOrderArgs {
1451            category,
1452            request: request_items,
1453        };
1454
1455        let request = BybitWsRequest {
1456            req_id: Some(batch_req_id.clone()),
1457            op: BybitWsOrderRequestOp::CreateBatch,
1458            header: BybitWsHeader::with_referer(referer),
1459            args: vec![args],
1460        };
1461
1462        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1463        self.send_text(&payload).await?;
1464
1465        Ok(batch_req_id)
1466    }
1467
1468    /// Batch amends multiple orders via WebSocket.
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if the batch request fails or if not authenticated.
1473    pub async fn batch_amend_orders(
1474        &self,
1475        orders: Vec<BybitWsAmendOrderParams>,
1476    ) -> BybitWsResult<Vec<String>> {
1477        self.require_authenticated().await?;
1478
1479        if orders.is_empty() {
1480            log::warn!("Batch amend orders called with empty orders list");
1481            return Ok(vec![]);
1482        }
1483
1484        let mut req_ids = Vec::new();
1485
1486        for chunk in orders.chunks(BATCH_PROCESSING_LIMIT) {
1487            let req_id = self.batch_amend_orders_chunk(chunk.to_vec()).await?;
1488            req_ids.push(req_id);
1489        }
1490
1491        Ok(req_ids)
1492    }
1493
1494    async fn batch_amend_orders_chunk(
1495        &self,
1496        orders: Vec<BybitWsAmendOrderParams>,
1497    ) -> BybitWsResult<String> {
1498        let batch_req_id = UUID4::new().to_string();
1499
1500        let request = BybitWsRequest {
1501            req_id: Some(batch_req_id.clone()),
1502            op: BybitWsOrderRequestOp::AmendBatch,
1503            header: BybitWsHeader::now(),
1504            args: orders,
1505        };
1506
1507        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1508        self.send_text(&payload).await?;
1509
1510        Ok(batch_req_id)
1511    }
1512
1513    /// Batch cancels multiple orders via WebSocket, returning the request ID for correlation.
1514    ///
1515    /// # Errors
1516    ///
1517    /// Returns an error if the batch request fails or if not authenticated.
1518    pub async fn batch_cancel_orders(
1519        &self,
1520        orders: Vec<BybitWsCancelOrderParams>,
1521    ) -> BybitWsResult<Vec<String>> {
1522        self.require_authenticated().await?;
1523
1524        if orders.is_empty() {
1525            log::warn!("Batch cancel orders called with empty orders list");
1526            return Ok(vec![]);
1527        }
1528
1529        let mut req_ids = Vec::new();
1530
1531        for chunk in orders.chunks(BATCH_PROCESSING_LIMIT) {
1532            let req_id = self.batch_cancel_orders_chunk(chunk.to_vec()).await?;
1533            req_ids.push(req_id);
1534        }
1535
1536        Ok(req_ids)
1537    }
1538
1539    async fn batch_cancel_orders_chunk(
1540        &self,
1541        orders: Vec<BybitWsCancelOrderParams>,
1542    ) -> BybitWsResult<String> {
1543        if orders.is_empty() {
1544            return Ok(String::new());
1545        }
1546
1547        let category = orders[0].category;
1548        let batch_req_id = UUID4::new().to_string();
1549
1550        let request_items: Vec<BybitWsBatchCancelItem> = orders
1551            .into_iter()
1552            .map(|order| BybitWsBatchCancelItem {
1553                symbol: order.symbol,
1554                order_id: order.order_id,
1555                order_link_id: order.order_link_id,
1556            })
1557            .collect();
1558
1559        let args = BybitWsBatchCancelOrderArgs {
1560            category,
1561            request: request_items,
1562        };
1563
1564        let request = BybitWsRequest {
1565            req_id: Some(batch_req_id.clone()),
1566            op: BybitWsOrderRequestOp::CancelBatch,
1567            header: BybitWsHeader::now(),
1568            args: vec![args],
1569        };
1570
1571        let payload = serde_json::to_string(&request).map_err(BybitWsError::from)?;
1572        self.send_text(&payload).await?;
1573
1574        Ok(batch_req_id)
1575    }
1576
1577    /// Submits an order using Nautilus domain objects.
1578    ///
1579    /// # Errors
1580    ///
1581    /// Returns an error if order submission fails or if not authenticated.
1582    #[expect(clippy::too_many_arguments)]
1583    pub async fn submit_order(
1584        &self,
1585        product_type: BybitProductType,
1586        instrument_id: InstrumentId,
1587        client_order_id: ClientOrderId,
1588        order_side: OrderSide,
1589        order_type: OrderType,
1590        quantity: Quantity,
1591        is_quote_quantity: bool,
1592        time_in_force: Option<TimeInForce>,
1593        price: Option<Price>,
1594        trigger_price: Option<Price>,
1595        trigger_type: Option<TriggerType>,
1596        post_only: Option<bool>,
1597        reduce_only: Option<bool>,
1598        is_leverage: bool,
1599        position_idx: Option<BybitPositionIdx>,
1600        bbo_side_type: Option<BybitBboSideType>,
1601        bbo_level: Option<String>,
1602    ) -> BybitWsResult<String> {
1603        let params = self.build_place_order_params(
1604            product_type,
1605            instrument_id,
1606            client_order_id,
1607            order_side,
1608            order_type,
1609            quantity,
1610            is_quote_quantity,
1611            time_in_force,
1612            price,
1613            trigger_price,
1614            trigger_type,
1615            post_only,
1616            reduce_only,
1617            is_leverage,
1618            None,
1619            None,
1620            position_idx,
1621            bbo_side_type,
1622            bbo_level,
1623        )?;
1624
1625        self.place_order(params).await
1626    }
1627
1628    /// Modifies an existing order using Nautilus domain objects.
1629    ///
1630    /// # Errors
1631    ///
1632    /// Returns an error if modification fails or if not authenticated.
1633    pub async fn modify_order(
1634        &self,
1635        product_type: BybitProductType,
1636        instrument_id: InstrumentId,
1637        client_order_id: ClientOrderId,
1638        venue_order_id: Option<VenueOrderId>,
1639        quantity: Option<Quantity>,
1640        price: Option<Price>,
1641    ) -> BybitWsResult<String> {
1642        let params = self.build_amend_order_params(
1643            product_type,
1644            instrument_id,
1645            venue_order_id,
1646            Some(client_order_id),
1647            quantity,
1648            price,
1649        )?;
1650
1651        self.amend_order(params).await
1652    }
1653
1654    /// Cancels an order using Nautilus domain objects.
1655    ///
1656    /// # Errors
1657    ///
1658    /// Returns an error if cancellation fails or if not authenticated.
1659    pub async fn cancel_order_by_id(
1660        &self,
1661        product_type: BybitProductType,
1662        instrument_id: InstrumentId,
1663        client_order_id: ClientOrderId,
1664        venue_order_id: Option<VenueOrderId>,
1665    ) -> BybitWsResult<String> {
1666        let params = self.build_cancel_order_params(
1667            product_type,
1668            instrument_id,
1669            venue_order_id,
1670            Some(client_order_id),
1671        )?;
1672
1673        self.cancel_order(params).await
1674    }
1675
1676    /// Builds order params for placing an order.
1677    #[expect(clippy::too_many_arguments)]
1678    pub fn build_place_order_params(
1679        &self,
1680        product_type: BybitProductType,
1681        instrument_id: InstrumentId,
1682        client_order_id: ClientOrderId,
1683        order_side: OrderSide,
1684        order_type: OrderType,
1685        quantity: Quantity,
1686        is_quote_quantity: bool,
1687        time_in_force: Option<TimeInForce>,
1688        price: Option<Price>,
1689        trigger_price: Option<Price>,
1690        trigger_type: Option<TriggerType>,
1691        post_only: Option<bool>,
1692        reduce_only: Option<bool>,
1693        is_leverage: bool,
1694        take_profit: Option<Price>,
1695        stop_loss: Option<Price>,
1696        position_idx: Option<BybitPositionIdx>,
1697        bbo_side_type: Option<BybitBboSideType>,
1698        bbo_level: Option<String>,
1699    ) -> BybitWsResult<BybitWsPlaceOrderParams> {
1700        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
1701            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1702        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
1703
1704        let bybit_side = match order_side {
1705            OrderSide::Buy => BybitOrderSide::Buy,
1706            OrderSide::Sell => BybitOrderSide::Sell,
1707            _ => {
1708                return Err(BybitWsError::ClientError(format!(
1709                    "Invalid order side: {order_side:?}"
1710                )));
1711            }
1712        };
1713
1714        let (bybit_order_type, is_stop_order) = match order_type {
1715            OrderType::Market => (BybitOrderType::Market, false),
1716            OrderType::Limit => (BybitOrderType::Limit, false),
1717            OrderType::StopMarket | OrderType::MarketIfTouched => (BybitOrderType::Market, true),
1718            OrderType::StopLimit | OrderType::LimitIfTouched => (BybitOrderType::Limit, true),
1719            _ => {
1720                return Err(BybitWsError::ClientError(format!(
1721                    "Unsupported order type: {order_type:?}"
1722                )));
1723            }
1724        };
1725
1726        let bybit_tif =
1727            map_time_in_force(bybit_order_type, time_in_force, post_only).map_err(|tif| {
1728                BybitWsError::ClientError(format!("Unsupported time in force: {tif:?}"))
1729            })?;
1730        let market_unit = spot_market_unit(product_type, bybit_order_type, is_quote_quantity);
1731        let is_leverage_value = spot_leverage(product_type, is_leverage);
1732        let trigger_dir =
1733            trigger_direction(order_type, order_side, is_stop_order).map(|d| d as i32);
1734
1735        let params = if is_stop_order {
1736            BybitWsPlaceOrderParams {
1737                category: product_type,
1738                symbol: raw_symbol,
1739                side: bybit_side,
1740                order_type: bybit_order_type,
1741                qty: quantity.to_string(),
1742                is_leverage: is_leverage_value,
1743                market_unit,
1744                price: if bbo_side_type.is_some() {
1745                    None
1746                } else {
1747                    price.map(|p| p.to_string())
1748                },
1749                time_in_force: bybit_tif,
1750                order_link_id: Some(client_order_id.to_string()),
1751                reduce_only: reduce_only.filter(|&r| r),
1752                close_on_trigger: None,
1753                trigger_price: trigger_price.map(|p| p.to_string()),
1754                trigger_by: Some(resolve_trigger_type(trigger_type)),
1755                trigger_direction: trigger_dir,
1756                tpsl_mode: if take_profit.is_some() || stop_loss.is_some() {
1757                    Some(BybitTpSlMode::Full)
1758                } else {
1759                    None
1760                },
1761                take_profit: take_profit.map(|p| p.to_string()),
1762                stop_loss: stop_loss.map(|p| p.to_string()),
1763                tp_trigger_by: take_profit.map(|_| resolve_trigger_type(trigger_type)),
1764                sl_trigger_by: stop_loss.map(|_| resolve_trigger_type(trigger_type)),
1765                sl_trigger_price: None,
1766                tp_trigger_price: None,
1767                sl_order_type: None,
1768                tp_order_type: None,
1769                sl_limit_price: None,
1770                tp_limit_price: None,
1771                order_iv: None,
1772                mmp: None,
1773                position_idx,
1774                bbo_side_type,
1775                bbo_level,
1776            }
1777        } else {
1778            BybitWsPlaceOrderParams {
1779                category: product_type,
1780                symbol: raw_symbol,
1781                side: bybit_side,
1782                order_type: bybit_order_type,
1783                qty: quantity.to_string(),
1784                is_leverage: is_leverage_value,
1785                market_unit,
1786                price: if bbo_side_type.is_some() {
1787                    None
1788                } else {
1789                    price.map(|p| p.to_string())
1790                },
1791                time_in_force: bybit_tif,
1792                order_link_id: Some(client_order_id.to_string()),
1793                reduce_only: reduce_only.filter(|&r| r),
1794                close_on_trigger: None,
1795                trigger_price: None,
1796                trigger_by: None,
1797                trigger_direction: None,
1798                tpsl_mode: if take_profit.is_some() || stop_loss.is_some() {
1799                    Some(BybitTpSlMode::Full)
1800                } else {
1801                    None
1802                },
1803                take_profit: take_profit.map(|p| p.to_string()),
1804                stop_loss: stop_loss.map(|p| p.to_string()),
1805                tp_trigger_by: take_profit.map(|_| resolve_trigger_type(trigger_type)),
1806                sl_trigger_by: stop_loss.map(|_| resolve_trigger_type(trigger_type)),
1807                sl_trigger_price: None,
1808                tp_trigger_price: None,
1809                sl_order_type: None,
1810                tp_order_type: None,
1811                sl_limit_price: None,
1812                tp_limit_price: None,
1813                order_iv: None,
1814                mmp: None,
1815                position_idx,
1816                bbo_side_type,
1817                bbo_level,
1818            }
1819        };
1820
1821        Ok(params)
1822    }
1823
1824    /// Builds order params for amending an order.
1825    pub fn build_amend_order_params(
1826        &self,
1827        product_type: BybitProductType,
1828        instrument_id: InstrumentId,
1829        venue_order_id: Option<VenueOrderId>,
1830        client_order_id: Option<ClientOrderId>,
1831        quantity: Option<Quantity>,
1832        price: Option<Price>,
1833    ) -> BybitWsResult<BybitWsAmendOrderParams> {
1834        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
1835            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1836        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
1837
1838        Ok(BybitWsAmendOrderParams {
1839            category: product_type,
1840            symbol: raw_symbol,
1841            order_id: venue_order_id.map(|v| v.to_string()),
1842            order_link_id: client_order_id.map(|c| c.to_string()),
1843            qty: quantity.map(|q| q.to_string()),
1844            price: price.map(|p| p.to_string()),
1845            trigger_price: None,
1846            take_profit: None,
1847            stop_loss: None,
1848            tp_trigger_by: None,
1849            sl_trigger_by: None,
1850            order_iv: None,
1851        })
1852    }
1853
1854    /// Builds order params for canceling an order via WebSocket.
1855    ///
1856    /// # Errors
1857    ///
1858    /// Returns an error if symbol parsing fails or if neither venue_order_id
1859    /// nor client_order_id is provided.
1860    pub fn build_cancel_order_params(
1861        &self,
1862        product_type: BybitProductType,
1863        instrument_id: InstrumentId,
1864        venue_order_id: Option<VenueOrderId>,
1865        client_order_id: Option<ClientOrderId>,
1866    ) -> BybitWsResult<BybitWsCancelOrderParams> {
1867        if venue_order_id.is_none() && client_order_id.is_none() {
1868            return Err(BybitWsError::ClientError(
1869                "Either venue_order_id or client_order_id must be provided".to_string(),
1870            ));
1871        }
1872
1873        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())
1874            .map_err(|e| BybitWsError::ClientError(e.to_string()))?;
1875        let raw_symbol = Ustr::from(bybit_symbol.raw_symbol());
1876
1877        Ok(BybitWsCancelOrderParams {
1878            category: product_type,
1879            symbol: raw_symbol,
1880            order_id: venue_order_id.map(|v| v.to_string()),
1881            order_link_id: client_order_id.map(|c| c.to_string()),
1882        })
1883    }
1884
1885    fn include_referer_header(&self, time_in_force: Option<BybitTimeInForce>) -> bool {
1886        let is_post_only = matches!(time_in_force, Some(BybitTimeInForce::PostOnly));
1887        let mm_level = self.mm_level.load(Ordering::Relaxed);
1888        !(is_post_only && mm_level > 0)
1889    }
1890
1891    fn default_headers() -> Vec<(String, String)> {
1892        vec![
1893            ("Content-Type".to_string(), "application/json".to_string()),
1894            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
1895        ]
1896    }
1897
1898    async fn authenticate_if_required(&self) -> BybitWsResult<()> {
1899        if !self.requires_auth {
1900            return Ok(());
1901        }
1902
1903        let credential = self.credential.as_ref().ok_or_else(|| {
1904            BybitWsError::Authentication("Credentials required for authentication".to_string())
1905        })?;
1906
1907        let expires = chrono::Utc::now().timestamp_millis() + WEBSOCKET_AUTH_WINDOW_MS;
1908        let signature = credential.sign_websocket_auth(expires);
1909
1910        let auth_message = BybitAuthRequest {
1911            op: BybitWsOperation::Auth,
1912            args: vec![
1913                Value::String(credential.api_key().to_string()),
1914                Value::Number(expires.into()),
1915                Value::String(signature),
1916            ],
1917        };
1918
1919        let payload = serde_json::to_string(&auth_message)?;
1920
1921        // Begin auth attempt so succeed() will update state
1922        let _rx = self.auth_tracker.begin();
1923
1924        self.cmd_tx
1925            .read()
1926            .await
1927            .send(HandlerCommand::Authenticate { payload })
1928            .map_err(|e| BybitWsError::Send(format!("Failed to send auth command: {e}")))?;
1929
1930        Ok(())
1931    }
1932
1933    async fn send_text(&self, text: &str) -> BybitWsResult<()> {
1934        let cmd = HandlerCommand::SendText {
1935            payload: text.to_string(),
1936        };
1937
1938        self.send_cmd(cmd).await
1939    }
1940
1941    async fn send_cmd(&self, cmd: HandlerCommand) -> BybitWsResult<()> {
1942        self.cmd_tx
1943            .read()
1944            .await
1945            .send(cmd)
1946            .map_err(|e| BybitWsError::Send(e.to_string()))
1947    }
1948}
1949
1950#[cfg(test)]
1951mod tests {
1952    use rstest::rstest;
1953
1954    use super::*;
1955    use crate::{
1956        common::{enums::BybitMarketUnit, testing::load_test_json},
1957        websocket::{messages::BybitWsFrame, parse_bybit_ws_frame},
1958    };
1959
1960    #[rstest]
1961    fn classify_orderbook_snapshot() {
1962        let json: Value = serde_json::from_str(&load_test_json("ws_orderbook_snapshot.json"))
1963            .expect("invalid fixture");
1964        let frame = parse_bybit_ws_frame(json);
1965        assert!(matches!(frame, BybitWsFrame::Orderbook(_)));
1966    }
1967
1968    #[rstest]
1969    fn classify_trade_snapshot() {
1970        let json: Value =
1971            serde_json::from_str(&load_test_json("ws_public_trade.json")).expect("invalid fixture");
1972        let frame = parse_bybit_ws_frame(json);
1973        assert!(matches!(frame, BybitWsFrame::Trade(_)));
1974    }
1975
1976    #[rstest]
1977    fn classify_ticker_linear_snapshot() {
1978        let json: Value = serde_json::from_str(&load_test_json("ws_ticker_linear.json"))
1979            .expect("invalid fixture");
1980        let frame = parse_bybit_ws_frame(json);
1981        assert!(matches!(frame, BybitWsFrame::TickerLinear(_)));
1982    }
1983
1984    #[rstest]
1985    fn classify_ticker_option_snapshot() {
1986        let json: Value = serde_json::from_str(&load_test_json("ws_ticker_option.json"))
1987            .expect("invalid fixture");
1988        let frame = parse_bybit_ws_frame(json);
1989        assert!(matches!(frame, BybitWsFrame::TickerOption(_)));
1990    }
1991
1992    #[rstest]
1993    fn test_race_unsubscribe_failure_recovery() {
1994        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
1995        let topic = "publicTrade.BTCUSDT";
1996
1997        subscriptions.mark_subscribe(topic);
1998        subscriptions.confirm_subscribe(topic);
1999        assert_eq!(subscriptions.len(), 1);
2000
2001        subscriptions.mark_unsubscribe(topic);
2002        assert_eq!(subscriptions.len(), 0);
2003        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2004
2005        subscriptions.confirm_unsubscribe(topic);
2006        subscriptions.mark_subscribe(topic);
2007        subscriptions.confirm_subscribe(topic);
2008
2009        assert_eq!(subscriptions.len(), 1);
2010        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2011        assert!(subscriptions.pending_subscribe_topics().is_empty());
2012
2013        let all = subscriptions.all_topics();
2014        assert_eq!(all.len(), 1);
2015        assert!(all.contains(&topic.to_string()));
2016    }
2017
2018    #[rstest]
2019    fn test_race_resubscribe_before_unsubscribe_ack() {
2020        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2021        let topic = "orderbook.50.BTCUSDT";
2022
2023        subscriptions.mark_subscribe(topic);
2024        subscriptions.confirm_subscribe(topic);
2025        assert_eq!(subscriptions.len(), 1);
2026
2027        subscriptions.mark_unsubscribe(topic);
2028        assert_eq!(subscriptions.len(), 0);
2029        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2030
2031        subscriptions.mark_subscribe(topic);
2032        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2033
2034        subscriptions.confirm_unsubscribe(topic);
2035        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2036        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2037
2038        subscriptions.confirm_subscribe(topic);
2039        assert_eq!(subscriptions.len(), 1);
2040        assert!(subscriptions.pending_subscribe_topics().is_empty());
2041
2042        let all = subscriptions.all_topics();
2043        assert_eq!(all.len(), 1);
2044        assert!(all.contains(&topic.to_string()));
2045    }
2046
2047    #[rstest]
2048    fn test_race_late_subscribe_confirmation_after_unsubscribe() {
2049        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2050        let topic = "tickers.ETHUSDT";
2051
2052        subscriptions.mark_subscribe(topic);
2053        assert_eq!(subscriptions.pending_subscribe_topics(), vec![topic]);
2054
2055        subscriptions.mark_unsubscribe(topic);
2056        assert!(subscriptions.pending_subscribe_topics().is_empty());
2057        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2058
2059        subscriptions.confirm_subscribe(topic);
2060        assert_eq!(subscriptions.len(), 0);
2061        assert_eq!(subscriptions.pending_unsubscribe_topics(), vec![topic]);
2062
2063        subscriptions.confirm_unsubscribe(topic);
2064
2065        assert!(subscriptions.is_empty());
2066        assert!(subscriptions.all_topics().is_empty());
2067    }
2068
2069    #[rstest]
2070    fn test_race_reconnection_with_pending_states() {
2071        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2072
2073        let trade_btc = "publicTrade.BTCUSDT";
2074        subscriptions.mark_subscribe(trade_btc);
2075        subscriptions.confirm_subscribe(trade_btc);
2076
2077        let trade_eth = "publicTrade.ETHUSDT";
2078        subscriptions.mark_subscribe(trade_eth);
2079
2080        let book_btc = "orderbook.50.BTCUSDT";
2081        subscriptions.mark_subscribe(book_btc);
2082        subscriptions.confirm_subscribe(book_btc);
2083        subscriptions.mark_unsubscribe(book_btc);
2084
2085        let topics_to_restore = subscriptions.all_topics();
2086
2087        assert_eq!(topics_to_restore.len(), 2);
2088        assert!(topics_to_restore.contains(&trade_btc.to_string()));
2089        assert!(topics_to_restore.contains(&trade_eth.to_string()));
2090        assert!(!topics_to_restore.contains(&book_btc.to_string()));
2091    }
2092
2093    #[rstest]
2094    fn test_race_duplicate_subscribe_messages_idempotent() {
2095        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
2096        let topic = "publicTrade.BTCUSDT";
2097
2098        subscriptions.mark_subscribe(topic);
2099        subscriptions.confirm_subscribe(topic);
2100        assert_eq!(subscriptions.len(), 1);
2101
2102        subscriptions.mark_subscribe(topic);
2103        assert!(subscriptions.pending_subscribe_topics().is_empty());
2104        assert_eq!(subscriptions.len(), 1);
2105
2106        subscriptions.confirm_subscribe(topic);
2107        assert_eq!(subscriptions.len(), 1);
2108
2109        let all = subscriptions.all_topics();
2110        assert_eq!(all.len(), 1);
2111        assert_eq!(all[0], topic);
2112    }
2113
2114    #[rstest]
2115    #[case::spot_with_leverage(BybitProductType::Spot, true, Some(1))]
2116    #[case::spot_without_leverage(BybitProductType::Spot, false, Some(0))]
2117    #[case::linear_with_leverage(BybitProductType::Linear, true, None)]
2118    #[case::linear_without_leverage(BybitProductType::Linear, false, None)]
2119    #[case::inverse_with_leverage(BybitProductType::Inverse, true, None)]
2120    #[case::option_with_leverage(BybitProductType::Option, true, None)]
2121    fn test_is_leverage_parameter(
2122        #[case] product_type: BybitProductType,
2123        #[case] is_leverage: bool,
2124        #[case] expected: Option<i32>,
2125    ) {
2126        let symbol = match product_type {
2127            BybitProductType::Spot => "BTCUSDT-SPOT.BYBIT",
2128            BybitProductType::Linear => "ETHUSDT-LINEAR.BYBIT",
2129            BybitProductType::Inverse => "BTCUSD-INVERSE.BYBIT",
2130            BybitProductType::Option => "BTC-31MAY24-50000-C-OPTION.BYBIT",
2131        };
2132
2133        let instrument_id = InstrumentId::from(symbol);
2134        let client_order_id = ClientOrderId::from("test-order-1");
2135        let quantity = Quantity::from("1.0");
2136
2137        let client = BybitWebSocketClient::new_trade(
2138            BybitEnvironment::Testnet,
2139            Some("test-key".to_string()),
2140            Some("test-secret".to_string()),
2141            None,
2142            20,
2143            TransportBackend::default(),
2144            None,
2145        );
2146
2147        let params = client
2148            .build_place_order_params(
2149                product_type,
2150                instrument_id,
2151                client_order_id,
2152                OrderSide::Buy,
2153                OrderType::Limit,
2154                quantity,
2155                false,
2156                Some(TimeInForce::Gtc),
2157                Some(Price::from("50000.0")),
2158                None,
2159                None,
2160                None,
2161                None,
2162                is_leverage,
2163                None,
2164                None,
2165                None,
2166                None,
2167                None,
2168            )
2169            .expect("Failed to build params");
2170
2171        assert_eq!(params.is_leverage, expected);
2172    }
2173
2174    #[rstest]
2175    #[case::spot_market_quote_quantity(
2176        BybitProductType::Spot,
2177        OrderType::Market,
2178        true,
2179        Some(BybitMarketUnit::QuoteCoin)
2180    )]
2181    #[case::spot_market_base_quantity(
2182        BybitProductType::Spot,
2183        OrderType::Market,
2184        false,
2185        Some(BybitMarketUnit::BaseCoin)
2186    )]
2187    #[case::spot_limit_no_unit(BybitProductType::Spot, OrderType::Limit, false, None)]
2188    #[case::spot_limit_quote(BybitProductType::Spot, OrderType::Limit, true, None)]
2189    #[case::linear_market_no_unit(BybitProductType::Linear, OrderType::Market, false, None)]
2190    #[case::inverse_market_no_unit(BybitProductType::Inverse, OrderType::Market, true, None)]
2191    fn test_is_quote_quantity_parameter(
2192        #[case] product_type: BybitProductType,
2193        #[case] order_type: OrderType,
2194        #[case] is_quote_quantity: bool,
2195        #[case] expected: Option<BybitMarketUnit>,
2196    ) {
2197        let symbol = match product_type {
2198            BybitProductType::Spot => "BTCUSDT-SPOT.BYBIT",
2199            BybitProductType::Linear => "ETHUSDT-LINEAR.BYBIT",
2200            BybitProductType::Inverse => "BTCUSD-INVERSE.BYBIT",
2201            BybitProductType::Option => "BTC-31MAY24-50000-C-OPTION.BYBIT",
2202        };
2203
2204        let instrument_id = InstrumentId::from(symbol);
2205        let client_order_id = ClientOrderId::from("test-order-1");
2206        let quantity = Quantity::from("1.0");
2207
2208        let client = BybitWebSocketClient::new_trade(
2209            BybitEnvironment::Testnet,
2210            Some("test-key".to_string()),
2211            Some("test-secret".to_string()),
2212            None,
2213            20,
2214            TransportBackend::default(),
2215            None,
2216        );
2217
2218        let params = client
2219            .build_place_order_params(
2220                product_type,
2221                instrument_id,
2222                client_order_id,
2223                OrderSide::Buy,
2224                order_type,
2225                quantity,
2226                is_quote_quantity,
2227                Some(TimeInForce::Gtc),
2228                if order_type == OrderType::Market {
2229                    None
2230                } else {
2231                    Some(Price::from("50000.0"))
2232                },
2233                None,
2234                None,
2235                None,
2236                None,
2237                false,
2238                None,
2239                None,
2240                None,
2241                None,
2242                None,
2243            )
2244            .expect("Failed to build params");
2245
2246        assert_eq!(params.market_unit, expected);
2247    }
2248
2249    #[rstest]
2250    fn test_build_place_order_params_with_bbo_omits_price() {
2251        let client = BybitWebSocketClient::new_trade(
2252            BybitEnvironment::Testnet,
2253            Some("test-key".to_string()),
2254            Some("test-secret".to_string()),
2255            None,
2256            20,
2257            TransportBackend::default(),
2258            None,
2259        );
2260
2261        let params = client
2262            .build_place_order_params(
2263                BybitProductType::Linear,
2264                InstrumentId::from("ETHUSDT-LINEAR.BYBIT"),
2265                ClientOrderId::from("test-bbo-order-1"),
2266                OrderSide::Buy,
2267                OrderType::Limit,
2268                Quantity::from("1.0"),
2269                false,
2270                Some(TimeInForce::Gtc),
2271                Some(Price::from("50000.0")),
2272                None,
2273                None,
2274                None,
2275                None,
2276                false,
2277                None,
2278                None,
2279                None,
2280                Some(BybitBboSideType::Queue),
2281                Some("2".to_string()),
2282            )
2283            .expect("Failed to build params");
2284
2285        assert_eq!(params.price, None);
2286        assert_eq!(params.bbo_side_type, Some(BybitBboSideType::Queue));
2287        assert_eq!(params.bbo_level.as_deref(), Some("2"));
2288    }
2289}