1use std::{
24 sync::{
25 Arc,
26 atomic::{AtomicBool, AtomicU8, Ordering},
27 },
28 time::Duration,
29};
30
31use arc_swap::ArcSwap;
32use dashmap::DashMap;
33use futures_util::Stream;
34use nautilus_common::live::get_runtime;
35use nautilus_core::{
36 consts::NAUTILUS_USER_AGENT,
37 env::{get_env_var, get_or_env_var_opt},
38};
39use nautilus_live::SocketControl;
40use nautilus_model::{
41 data::bar::BarType,
42 identifiers::{AccountId, InstrumentId},
43 instruments::{Instrument, InstrumentAny},
44};
45use nautilus_network::{
46 http::USER_AGENT,
47 mode::ConnectionMode,
48 websocket::{
49 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, SubscriptionState, TransportBackend,
50 WebSocketClient, WebSocketConfig, channel_message_handler,
51 },
52};
53use tokio_tungstenite::tungstenite::Message;
54use ustr::Ustr;
55
56use super::{
57 enums::{BitmexWsAuthAction, BitmexWsAuthChannel, BitmexWsOperation, BitmexWsTopic},
58 error::BitmexWsError,
59 handler::{BitmexWsFeedHandler, HandlerCommand},
60 messages::{BitmexAuthentication, BitmexSubscription, BitmexWsMessage},
61 parse::{is_index_symbol, topic_from_bar_spec},
62};
63use crate::common::{
64 consts::{BITMEX_WS_TOPIC_DELIMITER, BITMEX_WS_URL},
65 credential::{Credential, credential_env_vars},
66 enums::BitmexEnvironment,
67};
68
69#[derive(Clone, Debug)]
77pub struct BitmexWebSocketClient {
78 url: String,
79 credential: Option<Credential>,
80 heartbeat: Option<u64>,
81 auth_timeout_secs: u64,
82 account_id: AccountId,
83 auth_tracker: AuthTracker,
84 signal: Arc<AtomicBool>,
85 connection_mode: Arc<ArcSwap<AtomicU8>>,
86 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
87 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<BitmexWsMessage>>>,
88 task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
89 subscriptions: SubscriptionState,
90 tracked_subscriptions: Arc<DashMap<String, ()>>,
91 instruments: Arc<DashMap<Ustr, InstrumentAny>>,
92 transport_backend: TransportBackend,
93 proxy_url: Option<String>,
94 socket_control: Option<SocketControl>,
95}
96
97impl BitmexWebSocketClient {
98 #[expect(clippy::too_many_arguments)]
104 pub fn new(
105 url: Option<String>,
106 api_key: Option<String>,
107 api_secret: Option<String>,
108 account_id: Option<AccountId>,
109 heartbeat: u64,
110 auth_timeout_secs: Option<u64>,
111 transport_backend: TransportBackend,
112 proxy_url: Option<String>,
113 ) -> anyhow::Result<Self> {
114 let credential = match (api_key, api_secret) {
115 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
116 (None, None) => None,
117 _ => anyhow::bail!("Both `api_key` and `api_secret` must be provided together"),
118 };
119
120 let account_id = account_id.unwrap_or(AccountId::from("BITMEX-master"));
121
122 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
123 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
124
125 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
127
128 Ok(Self {
129 url: url.unwrap_or(BITMEX_WS_URL.to_string()),
130 credential,
131 heartbeat: Some(heartbeat),
132 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
133 account_id,
134 auth_tracker: AuthTracker::new(),
135 signal: Arc::new(AtomicBool::new(false)),
136 connection_mode,
137 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
138 out_rx: None,
139 task_handle: None,
140 subscriptions: SubscriptionState::new(BITMEX_WS_TOPIC_DELIMITER),
141 tracked_subscriptions: Arc::new(DashMap::new()),
142 instruments: Arc::new(DashMap::new()),
143 transport_backend,
144 proxy_url,
145 socket_control: None,
146 })
147 }
148
149 #[must_use]
151 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
152 self.socket_control = Some(control);
153 self
154 }
155
156 #[expect(clippy::too_many_arguments)]
167 pub fn new_with_env(
168 url: Option<String>,
169 api_key: Option<String>,
170 api_secret: Option<String>,
171 account_id: Option<AccountId>,
172 heartbeat: u64,
173 auth_timeout_secs: Option<u64>,
174 environment: BitmexEnvironment,
175 transport_backend: TransportBackend,
176 proxy_url: Option<String>,
177 ) -> anyhow::Result<Self> {
178 let (api_key_env, api_secret_env) = credential_env_vars(environment);
179
180 let key = get_or_env_var_opt(api_key, api_key_env);
181 let secret = get_or_env_var_opt(api_secret, api_secret_env);
182
183 Self::new(
184 url,
185 key,
186 secret,
187 account_id,
188 heartbeat,
189 auth_timeout_secs,
190 transport_backend,
191 proxy_url,
192 )
193 }
194
195 pub fn from_env() -> anyhow::Result<Self> {
201 let url = get_env_var("BITMEX_WS_URL")?;
202 let (key_var, secret_var) = credential_env_vars(BitmexEnvironment::Mainnet);
203 let api_key = get_env_var(key_var)?;
204 let api_secret = get_env_var(secret_var)?;
205
206 Self::new(
207 Some(url),
208 Some(api_key),
209 Some(api_secret),
210 None,
211 5,
212 None,
213 TransportBackend::default(),
214 None,
215 )
216 }
217
218 #[must_use]
220 pub const fn url(&self) -> &str {
221 self.url.as_str()
222 }
223
224 #[must_use]
226 pub fn api_key(&self) -> Option<&str> {
227 self.credential.as_ref().map(|c| c.api_key())
228 }
229
230 #[must_use]
232 pub fn api_key_masked(&self) -> Option<String> {
233 self.credential.as_ref().map(|c| c.api_key_masked())
234 }
235
236 #[must_use]
238 pub fn is_active(&self) -> bool {
239 let connection_mode_arc = self.connection_mode.load();
240 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
241 && !self.signal.load(Ordering::Relaxed)
242 }
243
244 #[must_use]
246 pub fn is_closed(&self) -> bool {
247 let connection_mode_arc = self.connection_mode.load();
248 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
249 || self.signal.load(Ordering::Relaxed)
250 }
251
252 #[must_use]
254 pub fn account_id(&self) -> AccountId {
255 self.account_id
256 }
257
258 pub fn set_account_id(&mut self, account_id: AccountId) {
260 self.account_id = account_id;
261 }
262
263 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
265 self.instruments.clear();
266 for inst in instruments {
267 self.instruments
268 .insert(inst.raw_symbol().inner(), inst.clone());
269 }
270 }
271
272 pub fn cache_instrument(&self, instrument: InstrumentAny) {
274 self.instruments
275 .insert(instrument.raw_symbol().inner(), instrument);
276 }
277
278 #[must_use]
280 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
281 self.instruments
282 .get(symbol)
283 .map(|entry| entry.value().clone())
284 }
285
286 pub async fn connect(&mut self) -> Result<(), BitmexWsError> {
292 let (client, raw_rx) = self.connect_inner().await?;
293
294 self.signal.store(false, Ordering::Relaxed);
296
297 self.connection_mode.store(client.connection_mode_atomic());
299 let reconnect_handle = client.reconnect_handle();
300
301 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<BitmexWsMessage>();
302 self.out_rx = Some(Arc::new(out_rx));
303
304 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
305 *self.cmd_tx.write().await = cmd_tx.clone();
306
307 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
309 return Err(BitmexWsError::ClientError(format!(
310 "Failed to send WebSocketClient to handler: {e}"
311 )));
312 }
313
314 if let Some(control) = &self.socket_control {
315 control.register(move || reconnect_handle.request_reconnect());
316 }
317
318 let signal = self.signal.clone();
319 let credential = self.credential.clone();
320 let auth_tracker = self.auth_tracker.clone();
321 let subscriptions = self.subscriptions.clone();
322 let cmd_tx_for_reconnect = cmd_tx.clone();
323
324 let stream_handle = get_runtime().spawn(async move {
325 let mut handler = BitmexWsFeedHandler::new(
326 signal.clone(),
327 cmd_rx,
328 raw_rx,
329 out_tx,
330 auth_tracker.clone(),
331 subscriptions.clone(),
332 );
333
334 let resubscribe_all = || {
336 let topics = subscriptions.all_topics();
338
339 if topics.is_empty() {
340 return;
341 }
342
343 log::debug!(
344 "Resubscribing to confirmed subscriptions: count={}",
345 topics.len()
346 );
347
348 for topic in &topics {
349 subscriptions.mark_subscribe(topic.as_str());
350 }
351
352 let mut payloads = Vec::with_capacity(topics.len());
354 for topic in &topics {
355 let message = BitmexSubscription {
356 op: BitmexWsOperation::Subscribe,
357 args: vec![Ustr::from(topic.as_ref())],
358 };
359
360 if let Ok(payload) = serde_json::to_string(&message) {
361 payloads.push(payload);
362 }
363 }
364
365 if let Err(e) =
366 cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { topics: payloads })
367 {
368 log::error!("Failed to send resubscribe command: {e}");
369 }
370 };
371
372 let mut waiting_for_reconnect_auth = false;
373
374 loop {
376 match handler.next().await {
377 Some(BitmexWsMessage::Reconnected) => {
378 if signal.load(Ordering::Relaxed) {
379 continue;
380 }
381
382 log::info!("WebSocket reconnected");
383
384 subscriptions.reset_after_reconnect();
385
386 if let Some(cred) = &credential {
387 log::debug!("Re-authenticating after reconnection");
388 waiting_for_reconnect_auth = true;
389
390 let expires = (jiff::Timestamp::now()
391 + jiff::SignedDuration::from_secs(30))
392 .as_second();
393 let signature = cred.sign("GET", "/realtime", expires, "");
394
395 let auth_message = BitmexAuthentication {
396 op: BitmexWsAuthAction::AuthKeyExpires,
397 args: (cred.api_key().to_string(), expires, signature),
398 };
399
400 if let Ok(payload) = serde_json::to_string(&auth_message) {
401 if let Err(e) = cmd_tx_for_reconnect
402 .send(HandlerCommand::Authenticate { payload })
403 {
404 log::error!("Failed to send reconnection auth command: {e}");
405 }
406 } else {
407 log::error!("Failed to serialize reconnection auth message");
408 }
409 }
410
411 if credential.is_none() {
414 log::debug!("No authentication required, resubscribing immediately");
415 resubscribe_all();
416 }
417
418 if handler.send(BitmexWsMessage::Reconnected).is_err() {
419 if handler.is_stopped() {
420 log::debug!("Failed to forward reconnect event (receiver dropped)");
421 } else {
422 log::error!("Failed to forward reconnect event (receiver dropped)");
423 }
424 break;
425 }
426 }
427 Some(BitmexWsMessage::Authenticated) => {
428 if waiting_for_reconnect_auth {
429 log::debug!("Authenticated after reconnection, resubscribing");
430 resubscribe_all();
431 waiting_for_reconnect_auth = false;
432 }
433 }
434 Some(msg) => {
435 if handler.send(msg).is_err() {
436 if handler.is_stopped() {
437 log::debug!("Failed to send message (receiver dropped)");
438 } else {
439 log::error!("Failed to send message (receiver dropped)");
440 }
441 break;
442 }
443 }
444 None => {
445 if handler.is_stopped() {
447 log::debug!("Stop signal received, ending message processing");
448 break;
449 }
450 log::warn!("WebSocket stream ended unexpectedly");
452 break;
453 }
454 }
455 }
456
457 log::debug!("Handler task exiting");
458 });
459
460 self.task_handle = Some(Arc::new(stream_handle));
461
462 if self.credential.is_some()
463 && let Err(e) = self.authenticate().await
464 {
465 if let Some(handle) = self.task_handle.take() {
466 handle.abort();
467 }
468 self.signal.store(true, Ordering::Relaxed);
469 return Err(e);
470 }
471
472 let instrument_topic = BitmexWsTopic::Instrument.as_ref().to_string();
474 self.subscriptions.mark_subscribe(&instrument_topic);
475 self.tracked_subscriptions.insert(instrument_topic, ());
476
477 let subscribe_msg = BitmexSubscription {
478 op: BitmexWsOperation::Subscribe,
479 args: vec![Ustr::from(BitmexWsTopic::Instrument.as_ref())],
480 };
481
482 match serde_json::to_string(&subscribe_msg) {
483 Ok(subscribe_json) => {
484 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Subscribe {
485 topics: vec![subscribe_json],
486 }) {
487 log::error!("Failed to send subscribe command for instruments: {e}");
488 } else {
489 log::debug!("Subscribed to all instruments");
490 }
491 }
492 Err(e) => {
493 log::error!("Failed to serialize subscribe message: {e}");
494 }
495 }
496
497 Ok(())
498 }
499
500 async fn connect_inner(
506 &self,
507 ) -> Result<
508 (
509 WebSocketClient,
510 tokio::sync::mpsc::UnboundedReceiver<Message>,
511 ),
512 BitmexWsError,
513 > {
514 let (message_handler, rx) = channel_message_handler();
515
516 let config = WebSocketConfig {
520 url: self.url.clone(),
521 headers: vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())],
522 heartbeat_interval_secs: self.heartbeat,
523 heartbeat_payload: None,
524 connect_timeout_ms: Some(5_000),
525 reconnect_delay_initial_ms: None, reconnect_delay_max_ms: None, reconnect_backoff_factor: None, reconnect_jitter_ms: None, reconnect_max_attempts: None,
530 heartbeat_timeout_secs: None,
531 idle_timeout_ms: None,
532 backend: self.transport_backend,
533 proxy_url: self.proxy_url.clone(),
534 };
535
536 let keyed_quotas = vec![];
537 let client = WebSocketClient::builder()
538 .config(config)
539 .message_handler(message_handler)
540 .keyed_quotas(keyed_quotas)
541 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
542 .connect()
543 .await
544 .map_err(|e| BitmexWsError::ClientError(e.to_string()))?;
545
546 Ok((client, rx))
547 }
548
549 async fn authenticate(&self) -> Result<(), BitmexWsError> {
556 let credential = match &self.credential {
557 Some(credential) => credential,
558 None => {
559 return Err(BitmexWsError::AuthenticationError(
560 "API credentials not available to authenticate".to_string(),
561 ));
562 }
563 };
564
565 let receiver = self.auth_tracker.begin();
566
567 let expires = (jiff::Timestamp::now() + jiff::SignedDuration::from_secs(30)).as_second();
568 let signature = credential.sign("GET", "/realtime", expires, "");
569
570 let auth_message = BitmexAuthentication {
571 op: BitmexWsAuthAction::AuthKeyExpires,
572 args: (credential.api_key().to_string(), expires, signature),
573 };
574
575 let auth_json = serde_json::to_string(&auth_message).map_err(|e| {
576 let msg = format!("Failed to serialize auth message: {e}");
577 self.auth_tracker.fail(msg.clone());
578 BitmexWsError::AuthenticationError(msg)
579 })?;
580
581 self.cmd_tx
583 .read()
584 .await
585 .send(HandlerCommand::Authenticate { payload: auth_json })
586 .map_err(|e| {
587 let msg = format!("Failed to send authenticate command: {e}");
588 self.auth_tracker.fail(msg.clone());
589 BitmexWsError::AuthenticationError(msg)
590 })?;
591
592 self.auth_tracker
593 .wait_for_result::<BitmexWsError>(Duration::from_secs(self.auth_timeout_secs), receiver)
594 .await
595 }
596
597 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), BitmexWsError> {
603 let timeout = Duration::from_secs_f64(timeout_secs);
604
605 tokio::time::timeout(timeout, async {
606 while !self.is_active() {
607 tokio::time::sleep(Duration::from_millis(10)).await;
608 }
609 })
610 .await
611 .map_err(|_| {
612 BitmexWsError::ClientError(format!(
613 "WebSocket connection timeout after {timeout_secs} seconds"
614 ))
615 })?;
616
617 Ok(())
618 }
619
620 pub fn stream(&mut self) -> impl Stream<Item = BitmexWsMessage> + use<> {
628 let rx = self
629 .out_rx
630 .take()
631 .expect("Stream receiver already taken or not connected");
632 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
633 async_stream::stream! {
634 while let Some(msg) = rx.recv().await {
635 yield msg;
636 }
637 }
638 }
639
640 pub async fn close(&mut self) -> Result<(), BitmexWsError> {
646 log::debug!("Starting close process");
647
648 self.signal.store(true, Ordering::Relaxed);
649
650 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
652 log::debug!(
653 "Failed to send disconnect command (handler may already be shut down): {e}"
654 );
655 }
656
657 if let Some(task_handle) = self.task_handle.take() {
659 match Arc::try_unwrap(task_handle) {
660 Ok(handle) => {
661 log::debug!("Waiting for task handle to complete");
662 match tokio::time::timeout(Duration::from_secs(2), handle).await {
663 Ok(Ok(())) => log::debug!("Task handle completed successfully"),
664 Ok(Err(e)) => log::error!("Task handle encountered an error: {e:?}"),
665 Err(_) => {
666 log::warn!(
667 "Timeout waiting for task handle, task may still be running"
668 );
669 }
671 }
672 }
673 Err(arc_handle) => {
674 log::debug!(
675 "Cannot take ownership of task handle - other references exist, aborting task"
676 );
677 arc_handle.abort();
678 }
679 }
680 } else {
681 log::debug!("No task handle to await");
682 }
683
684 log::debug!("Closed");
685
686 if let Some(control) = &self.socket_control {
687 control.deregister();
688 }
689
690 Ok(())
691 }
692
693 pub async fn subscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
699 log::debug!("Subscribing to topics: {topics:?}");
700
701 for topic in &topics {
702 self.subscriptions.mark_subscribe(topic.as_str());
703 self.tracked_subscriptions.insert(topic.clone(), ());
704 }
705
706 let mut payloads = Vec::with_capacity(topics.len());
708 for topic in &topics {
709 let message = BitmexSubscription {
710 op: BitmexWsOperation::Subscribe,
711 args: vec![Ustr::from(topic.as_ref())],
712 };
713 let payload = serde_json::to_string(&message).map_err(|e| {
714 BitmexWsError::SubscriptionError(format!("Failed to serialize subscription: {e}"))
715 })?;
716 payloads.push(payload);
717 }
718
719 let cmd = HandlerCommand::Subscribe { topics: payloads };
721
722 self.send_cmd(cmd).await.map_err(|e| {
723 BitmexWsError::SubscriptionError(format!("Failed to send subscribe command: {e}"))
724 })
725 }
726
727 async fn unsubscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
733 log::debug!("Attempting to unsubscribe from topics: {topics:?}");
734
735 if self.signal.load(Ordering::Relaxed) {
736 log::debug!("Shutdown signal detected, skipping unsubscribe");
737 return Ok(());
738 }
739
740 for topic in &topics {
741 self.subscriptions.mark_unsubscribe(topic.as_str());
742 self.tracked_subscriptions.remove(topic);
743 }
744
745 let mut payloads = Vec::with_capacity(topics.len());
747 for topic in &topics {
748 let message = BitmexSubscription {
749 op: BitmexWsOperation::Unsubscribe,
750 args: vec![Ustr::from(topic.as_ref())],
751 };
752
753 if let Ok(payload) = serde_json::to_string(&message) {
754 payloads.push(payload);
755 }
756 }
757
758 let cmd = HandlerCommand::Unsubscribe { topics: payloads };
760
761 if let Err(e) = self.send_cmd(cmd).await {
762 log::debug!("Failed to send unsubscribe command: {e}");
763 }
764
765 Ok(())
766 }
767
768 #[must_use]
770 pub fn subscription_count(&self) -> usize {
771 self.subscriptions.len()
772 }
773
774 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<String> {
775 let symbol = instrument_id.symbol.inner();
776 let confirmed = self.subscriptions.confirmed();
777 let mut channels = Vec::with_capacity(confirmed.len());
778
779 for (channel, symbols) in confirmed.iter() {
780 if symbols.contains(&symbol) {
781 channels.push(format!("{channel}:{symbol}"));
783 } else {
784 let has_channel_marker = symbols.iter().any(|s| s.is_empty());
785 if has_channel_marker
786 && (*channel == BitmexWsAuthChannel::Execution.as_ref()
787 || *channel == BitmexWsAuthChannel::Order.as_ref())
788 {
789 channels.push(channel.to_string());
791 }
792 }
793 }
794
795 channels
796 }
797
798 pub async fn subscribe_instruments(&self) -> Result<(), BitmexWsError> {
804 log::debug!("Already subscribed to all instruments on connection, skipping");
806 Ok(())
807 }
808
809 pub async fn subscribe_instrument(
815 &self,
816 instrument_id: InstrumentId,
817 ) -> Result<(), BitmexWsError> {
818 log::debug!(
820 "Already subscribed to all instruments on connection (includes {instrument_id}), skipping"
821 );
822 Ok(())
823 }
824
825 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
831 let topic = BitmexWsTopic::OrderBookL2;
832 let symbol = instrument_id.symbol.inner();
833 self.subscribe(vec![format!("{topic}:{symbol}")]).await
834 }
835
836 pub async fn subscribe_book_25(
842 &self,
843 instrument_id: InstrumentId,
844 ) -> Result<(), BitmexWsError> {
845 let topic = BitmexWsTopic::OrderBookL2_25;
846 let symbol = instrument_id.symbol.inner();
847 self.subscribe(vec![format!("{topic}:{symbol}")]).await
848 }
849
850 pub async fn subscribe_book_depth10(
856 &self,
857 instrument_id: InstrumentId,
858 ) -> Result<(), BitmexWsError> {
859 let topic = BitmexWsTopic::OrderBook10;
860 let symbol = instrument_id.symbol.inner();
861 self.subscribe(vec![format!("{topic}:{symbol}")]).await
862 }
863
864 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
872 let symbol = instrument_id.symbol.inner();
873
874 if is_index_symbol(&instrument_id.symbol.inner()) {
876 log::warn!("Ignoring quote subscription for index symbol: {symbol}");
877 return Ok(());
878 }
879
880 let topic = BitmexWsTopic::Quote;
881 self.subscribe(vec![format!("{topic}:{symbol}")]).await
882 }
883
884 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
892 let symbol = instrument_id.symbol.inner();
893
894 if is_index_symbol(&symbol) {
896 log::warn!("Ignoring trade subscription for index symbol: {symbol}");
897 return Ok(());
898 }
899
900 let topic = BitmexWsTopic::Trade;
901 self.subscribe(vec![format!("{topic}:{symbol}")]).await
902 }
903
904 pub async fn subscribe_mark_prices(
910 &self,
911 instrument_id: InstrumentId,
912 ) -> Result<(), BitmexWsError> {
913 self.subscribe_instrument(instrument_id).await
914 }
915
916 pub async fn subscribe_index_prices(
922 &self,
923 instrument_id: InstrumentId,
924 ) -> Result<(), BitmexWsError> {
925 self.subscribe_instrument(instrument_id).await
926 }
927
928 pub async fn subscribe_funding_rates(
934 &self,
935 instrument_id: InstrumentId,
936 ) -> Result<(), BitmexWsError> {
937 let topic = BitmexWsTopic::Funding;
938 let symbol = instrument_id.symbol.inner();
939 self.subscribe(vec![format!("{topic}:{symbol}")]).await
940 }
941
942 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
948 let topic = topic_from_bar_spec(bar_type.spec());
949 let symbol = bar_type.instrument_id().symbol.inner();
950 self.subscribe(vec![format!("{topic}:{symbol}")]).await
951 }
952
953 pub async fn unsubscribe_instruments(&self) -> Result<(), BitmexWsError> {
959 log::debug!(
961 "Instruments subscription maintained for proper operation, skipping unsubscribe"
962 );
963 Ok(())
964 }
965
966 pub async fn unsubscribe_instrument(
972 &self,
973 instrument_id: InstrumentId,
974 ) -> Result<(), BitmexWsError> {
975 log::debug!(
977 "Instruments subscription maintained for proper operation (includes {instrument_id}), skipping unsubscribe"
978 );
979 Ok(())
980 }
981
982 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
988 let topic = BitmexWsTopic::OrderBookL2;
989 let symbol = instrument_id.symbol.inner();
990 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
991 }
992
993 pub async fn unsubscribe_book_25(
999 &self,
1000 instrument_id: InstrumentId,
1001 ) -> Result<(), BitmexWsError> {
1002 let topic = BitmexWsTopic::OrderBookL2_25;
1003 let symbol = instrument_id.symbol.inner();
1004 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1005 }
1006
1007 pub async fn unsubscribe_book_depth10(
1013 &self,
1014 instrument_id: InstrumentId,
1015 ) -> Result<(), BitmexWsError> {
1016 let topic = BitmexWsTopic::OrderBook10;
1017 let symbol = instrument_id.symbol.inner();
1018 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1019 }
1020
1021 pub async fn unsubscribe_quotes(
1027 &self,
1028 instrument_id: InstrumentId,
1029 ) -> Result<(), BitmexWsError> {
1030 let symbol = instrument_id.symbol.inner();
1031
1032 if is_index_symbol(&symbol) {
1034 return Ok(());
1035 }
1036
1037 let topic = BitmexWsTopic::Quote;
1038 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1039 }
1040
1041 pub async fn unsubscribe_trades(
1047 &self,
1048 instrument_id: InstrumentId,
1049 ) -> Result<(), BitmexWsError> {
1050 let symbol = instrument_id.symbol.inner();
1051
1052 if is_index_symbol(&symbol) {
1054 return Ok(());
1055 }
1056
1057 let topic = BitmexWsTopic::Trade;
1058 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1059 }
1060
1061 pub async fn unsubscribe_mark_prices(
1067 &self,
1068 instrument_id: InstrumentId,
1069 ) -> Result<(), BitmexWsError> {
1070 log::debug!(
1072 "Mark prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1073 );
1074 Ok(())
1075 }
1076
1077 pub async fn unsubscribe_index_prices(
1083 &self,
1084 instrument_id: InstrumentId,
1085 ) -> Result<(), BitmexWsError> {
1086 log::debug!(
1088 "Index prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1089 );
1090 Ok(())
1091 }
1092
1093 pub async fn unsubscribe_funding_rates(
1099 &self,
1100 instrument_id: InstrumentId,
1101 ) -> Result<(), BitmexWsError> {
1102 log::debug!(
1104 "Funding rates for {instrument_id}, skipping unsubscribe to avoid shutdown race"
1105 );
1106 Ok(())
1107 }
1108
1109 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
1115 let topic = topic_from_bar_spec(bar_type.spec());
1116 let symbol = bar_type.instrument_id().symbol.inner();
1117 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1118 }
1119
1120 pub async fn subscribe_orders(&self) -> Result<(), BitmexWsError> {
1126 if self.credential.is_none() {
1127 return Err(BitmexWsError::MissingCredentials);
1128 }
1129 self.subscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1130 .await
1131 }
1132
1133 pub async fn subscribe_executions(&self) -> Result<(), BitmexWsError> {
1139 if self.credential.is_none() {
1140 return Err(BitmexWsError::MissingCredentials);
1141 }
1142 self.subscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1143 .await
1144 }
1145
1146 pub async fn subscribe_positions(&self) -> Result<(), BitmexWsError> {
1152 if self.credential.is_none() {
1153 return Err(BitmexWsError::MissingCredentials);
1154 }
1155 self.subscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1156 .await
1157 }
1158
1159 pub async fn subscribe_margin(&self) -> Result<(), BitmexWsError> {
1165 if self.credential.is_none() {
1166 return Err(BitmexWsError::MissingCredentials);
1167 }
1168 self.subscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1169 .await
1170 }
1171
1172 pub async fn subscribe_wallet(&self) -> Result<(), BitmexWsError> {
1178 if self.credential.is_none() {
1179 return Err(BitmexWsError::MissingCredentials);
1180 }
1181 self.subscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1182 .await
1183 }
1184
1185 pub async fn unsubscribe_orders(&self) -> Result<(), BitmexWsError> {
1191 self.unsubscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1192 .await
1193 }
1194
1195 pub async fn unsubscribe_executions(&self) -> Result<(), BitmexWsError> {
1201 self.unsubscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1202 .await
1203 }
1204
1205 pub async fn unsubscribe_positions(&self) -> Result<(), BitmexWsError> {
1211 self.unsubscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1212 .await
1213 }
1214
1215 pub async fn unsubscribe_margin(&self) -> Result<(), BitmexWsError> {
1221 self.unsubscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1222 .await
1223 }
1224
1225 pub async fn unsubscribe_wallet(&self) -> Result<(), BitmexWsError> {
1231 self.unsubscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1232 .await
1233 }
1234
1235 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), BitmexWsError> {
1237 self.cmd_tx
1238 .read()
1239 .await
1240 .send(cmd)
1241 .map_err(|e| BitmexWsError::ClientError(format!("Handler not available: {e}")))
1242 }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use rstest::rstest;
1248
1249 use super::*;
1250
1251 #[rstest]
1252 fn test_reconnect_topics_restoration_logic() {
1253 let client = BitmexWebSocketClient::new(
1255 Some("ws://test.com".to_string()),
1256 Some("test_key".to_string()),
1257 Some("test_secret".to_string()),
1258 Some(AccountId::new("BITMEX-TEST")),
1259 5,
1260 None,
1261 TransportBackend::default(),
1262 None,
1263 )
1264 .unwrap();
1265
1266 for topic in [
1268 format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref()),
1269 format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref()),
1270 format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()),
1271 BitmexWsAuthChannel::Order.as_ref().to_string(),
1272 BitmexWsAuthChannel::Position.as_ref().to_string(),
1273 ] {
1274 client.subscriptions.mark_subscribe(&topic);
1275 client.subscriptions.confirm_subscribe(&topic);
1276 }
1277
1278 let topics_to_restore = client.subscriptions.all_topics();
1280
1281 assert!(topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref())));
1283 assert!(topics_to_restore.contains(&format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref())));
1284 assert!(
1285 topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()))
1286 );
1287 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Order.as_ref().to_string()));
1288 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Position.as_ref().to_string()));
1289 assert_eq!(topics_to_restore.len(), 5);
1290 }
1291
1292 #[rstest]
1293 fn test_reconnect_auth_message_building() {
1294 let client_with_creds = BitmexWebSocketClient::new(
1296 Some("ws://test.com".to_string()),
1297 Some("test_key".to_string()),
1298 Some("test_secret".to_string()),
1299 Some(AccountId::new("BITMEX-TEST")),
1300 5,
1301 None,
1302 TransportBackend::default(),
1303 None,
1304 )
1305 .unwrap();
1306
1307 if let Some(cred) = &client_with_creds.credential {
1309 let expires =
1310 (jiff::Timestamp::now() + jiff::SignedDuration::from_secs(30)).as_second();
1311 let signature = cred.sign("GET", "/realtime", expires, "");
1312
1313 let auth_message = BitmexAuthentication {
1314 op: BitmexWsAuthAction::AuthKeyExpires,
1315 args: (cred.api_key().to_string(), expires, signature),
1316 };
1317
1318 assert_eq!(auth_message.op, BitmexWsAuthAction::AuthKeyExpires);
1320 assert_eq!(auth_message.args.0, "test_key");
1321 assert!(auth_message.args.1 > 0); assert!(!auth_message.args.2.is_empty()); } else {
1324 panic!("Client should have credentials");
1325 }
1326
1327 let client_no_creds = BitmexWebSocketClient::new(
1329 Some("ws://test.com".to_string()),
1330 None,
1331 None,
1332 Some(AccountId::new("BITMEX-TEST")),
1333 5,
1334 None,
1335 TransportBackend::default(),
1336 None,
1337 )
1338 .unwrap();
1339
1340 assert!(client_no_creds.credential.is_none());
1341 }
1342
1343 #[rstest]
1344 fn test_subscription_state_after_unsubscribe() {
1345 let client = BitmexWebSocketClient::new(
1346 Some("ws://test.com".to_string()),
1347 Some("test_key".to_string()),
1348 Some("test_secret".to_string()),
1349 Some(AccountId::new("BITMEX-TEST")),
1350 5,
1351 None,
1352 TransportBackend::default(),
1353 None,
1354 )
1355 .unwrap();
1356
1357 for topic in [
1359 format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref()),
1360 format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref()),
1361 format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()),
1362 ] {
1363 client.subscriptions.mark_subscribe(&topic);
1364 client.subscriptions.confirm_subscribe(&topic);
1365 }
1366
1367 let topic = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1369 client.subscriptions.mark_unsubscribe(&topic);
1370 client.subscriptions.confirm_unsubscribe(&topic);
1371
1372 let topics_to_restore = client.subscriptions.all_topics();
1374
1375 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1377 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1378 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1379
1380 assert!(topics_to_restore.contains(&trade_xbt));
1381 assert!(!topics_to_restore.contains(&trade_eth));
1382 assert!(topics_to_restore.contains(&book_xbt));
1383 assert_eq!(topics_to_restore.len(), 2);
1384 }
1385
1386 #[rstest]
1387 fn test_race_unsubscribe_failure_recovery() {
1388 let client = BitmexWebSocketClient::new(
1394 Some("ws://test.com".to_string()),
1395 None,
1396 None,
1397 Some(AccountId::new("BITMEX-TEST")),
1398 5,
1399 None,
1400 TransportBackend::default(),
1401 None,
1402 )
1403 .unwrap();
1404
1405 let topic = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1406
1407 client.subscriptions.mark_subscribe(&topic);
1409 client.subscriptions.confirm_subscribe(&topic);
1410 assert_eq!(client.subscriptions.len(), 1);
1411
1412 client.subscriptions.mark_unsubscribe(&topic);
1414 assert_eq!(client.subscriptions.len(), 0);
1415 assert_eq!(
1416 client.subscriptions.pending_unsubscribe_topics(),
1417 vec![topic.clone()]
1418 );
1419
1420 client.subscriptions.confirm_unsubscribe(&topic); client.subscriptions.mark_subscribe(&topic); client.subscriptions.confirm_subscribe(&topic); assert_eq!(client.subscriptions.len(), 1);
1428 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1429 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1430
1431 let all = client.subscriptions.all_topics();
1433 assert_eq!(all.len(), 1);
1434 assert!(all.contains(&topic));
1435 }
1436
1437 #[rstest]
1438 fn test_race_resubscribe_before_unsubscribe_ack() {
1439 let client = BitmexWebSocketClient::new(
1443 Some("ws://test.com".to_string()),
1444 None,
1445 None,
1446 Some(AccountId::new("BITMEX-TEST")),
1447 5,
1448 None,
1449 TransportBackend::default(),
1450 None,
1451 )
1452 .unwrap();
1453
1454 let topic = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1455
1456 client.subscriptions.mark_subscribe(&topic);
1458 client.subscriptions.confirm_subscribe(&topic);
1459 assert_eq!(client.subscriptions.len(), 1);
1460
1461 client.subscriptions.mark_unsubscribe(&topic);
1463 assert_eq!(client.subscriptions.len(), 0);
1464 assert_eq!(
1465 client.subscriptions.pending_unsubscribe_topics(),
1466 vec![topic.clone()]
1467 );
1468
1469 client.subscriptions.mark_subscribe(&topic);
1471 assert_eq!(
1472 client.subscriptions.pending_subscribe_topics(),
1473 vec![topic.clone()]
1474 );
1475
1476 client.subscriptions.confirm_unsubscribe(&topic);
1478 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1479 assert_eq!(
1480 client.subscriptions.pending_subscribe_topics(),
1481 vec![topic.clone()]
1482 );
1483
1484 client.subscriptions.confirm_subscribe(&topic);
1486 assert_eq!(client.subscriptions.len(), 1);
1487 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1488
1489 let all = client.subscriptions.all_topics();
1491 assert_eq!(all.len(), 1);
1492 assert!(all.contains(&topic));
1493 }
1494
1495 #[rstest]
1496 fn test_race_channel_level_reconnection_with_pending_states() {
1497 let client = BitmexWebSocketClient::new(
1499 Some("ws://test.com".to_string()),
1500 Some("test_key".to_string()),
1501 Some("test_secret".to_string()),
1502 Some(AccountId::new("BITMEX-TEST")),
1503 5,
1504 None,
1505 TransportBackend::default(),
1506 None,
1507 )
1508 .unwrap();
1509
1510 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1513 client.subscriptions.mark_subscribe(&trade_xbt);
1514 client.subscriptions.confirm_subscribe(&trade_xbt);
1515
1516 let order_channel = BitmexWsAuthChannel::Order.as_ref();
1518 client.subscriptions.mark_subscribe(order_channel);
1519 client.subscriptions.confirm_subscribe(order_channel);
1520
1521 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1523 client.subscriptions.mark_subscribe(&trade_eth);
1524
1525 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1527 client.subscriptions.mark_subscribe(&book_xbt);
1528 client.subscriptions.confirm_subscribe(&book_xbt);
1529 client.subscriptions.mark_unsubscribe(&book_xbt);
1530
1531 let topics_to_restore = client.subscriptions.all_topics();
1533
1534 assert_eq!(topics_to_restore.len(), 3);
1536 assert!(topics_to_restore.contains(&trade_xbt));
1537 assert!(topics_to_restore.contains(&order_channel.to_string()));
1538 assert!(topics_to_restore.contains(&trade_eth));
1539 assert!(!topics_to_restore.contains(&book_xbt)); for topic in &topics_to_restore {
1544 if topic == order_channel {
1545 assert!(
1546 !topic.contains(':'),
1547 "Channel-level topic should not have delimiter"
1548 );
1549 }
1550 }
1551 }
1552}