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