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