1use 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);
97pub const BATCH_PROCESSING_LIMIT: usize = 20;
99pub 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, 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 #[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 pub fn set_auth_wait_timeout(&mut self, timeout: Duration) {
238 self.auth_wait_timeout = timeout;
239 }
240
241 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 #[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 #[must_use]
298 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
299 self.socket_control = Some(control);
300 self
301 }
302
303 #[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 #[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 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 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 cmd_tx
528 .send(HandlerCommand::SetClient(client))
529 .map_err(|e| BybitWsError::Send(e.to_string()))?;
530
531 {
532 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 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 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 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 if !requires_auth {
648 log::debug!("No authentication required, resubscribing immediately");
649 resubscribe_all().await;
650 }
651
652 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 if handler.is_stopped() {
691 log::debug!("Stop signal received, ending message processing");
692 break;
693 }
694 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 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 #[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 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 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 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 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 let mut topics_to_send = Vec::new();
876
877 for topic in topics {
878 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 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 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 let mut topics_to_send = Vec::new();
920
921 for topic in topics {
922 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 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 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 #[must_use]
977 pub fn subscription_count(&self) -> usize {
978 self.subscriptions.len()
979 }
980
981 #[must_use]
983 pub fn credential(&self) -> Option<&Credential> {
984 self.credential.as_ref()
985 }
986
987 pub fn set_account_id(&mut self, account_id: AccountId) {
989 self.account_id = Some(account_id);
990 }
991
992 pub fn set_mm_level(&self, mm_level: u8) {
994 self.mm_level.store(mm_level, Ordering::Relaxed);
995 }
996
997 #[must_use]
999 pub fn account_id(&self) -> Option<AccountId> {
1000 self.account_id
1001 }
1002
1003 #[must_use]
1005 pub fn product_type(&self) -> Option<BybitProductType> {
1006 self.product_type
1007 }
1008
1009 #[must_use]
1011 pub fn bar_types_cache(&self) -> &Arc<AtomicMap<String, BarType>> {
1012 &self.bar_types_cache
1013 }
1014
1015 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1017 self.instruments_cache
1018 .insert(instrument.id().symbol.inner(), instrument);
1019 }
1020
1021 #[must_use]
1023 pub fn instruments_snapshot(&self) -> ahash::AHashMap<Ustr, InstrumentAny> {
1024 (**self.instruments_cache.load()).clone()
1025 }
1026
1027 pub fn add_option_greeks_sub(&self, instrument_id: InstrumentId) {
1029 self.option_greeks_subs.insert(instrument_id);
1030 }
1031
1032 pub fn remove_option_greeks_sub(&self, instrument_id: &InstrumentId) {
1034 self.option_greeks_subs.remove(instrument_id);
1035 }
1036
1037 #[must_use]
1039 pub fn option_greeks_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
1040 &self.option_greeks_subs
1041 }
1042
1043 #[must_use]
1045 pub fn trade_subs(&self) -> &Arc<AtomicSet<InstrumentId>> {
1046 &self.trade_subs
1047 }
1048
1049 #[must_use]
1051 pub fn instruments_cache_ref(&self) -> &Arc<AtomicMap<Ustr, InstrumentAny>> {
1052 &self.instruments_cache
1053 }
1054
1055 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn unsubscribe_orders(&self) -> BybitWsResult<()> {
1346 self.unsubscribe(vec![BybitWsPrivateChannel::Order.as_ref().to_string()])
1347 .await
1348 }
1349
1350 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 pub async fn unsubscribe_executions(&self) -> BybitWsResult<()> {
1371 self.unsubscribe(vec![BybitWsPrivateChannel::Execution.as_ref().to_string()])
1372 .await
1373 }
1374
1375 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 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 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 pub async fn unsubscribe_positions(&self) -> BybitWsResult<()> {
1425 self.unsubscribe(vec![BybitWsPrivateChannel::Position.as_ref().to_string()])
1426 .await
1427 }
1428
1429 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 pub async fn unsubscribe_wallet(&self) -> BybitWsResult<()> {
1450 self.unsubscribe(vec![BybitWsPrivateChannel::Wallet.as_ref().to_string()])
1451 .await
1452 }
1453
1454 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 #[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 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 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 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 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 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 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 #[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 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 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 #[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 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 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 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 #[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}