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_model::{
40 data::bar::BarType,
41 identifiers::{AccountId, InstrumentId},
42 instruments::{Instrument, InstrumentAny},
43};
44use nautilus_network::{
45 http::USER_AGENT,
46 mode::ConnectionMode,
47 websocket::{
48 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, PingHandler, SubscriptionState, TransportBackend,
49 WebSocketClient, WebSocketConfig, channel_message_handler,
50 },
51};
52use tokio_tungstenite::tungstenite::Message;
53use ustr::Ustr;
54
55use super::{
56 enums::{BitmexWsAuthAction, BitmexWsAuthChannel, BitmexWsOperation, BitmexWsTopic},
57 error::BitmexWsError,
58 handler::{BitmexWsFeedHandler, HandlerCommand},
59 messages::{BitmexAuthentication, BitmexSubscription, BitmexWsMessage},
60 parse::{is_index_symbol, topic_from_bar_spec},
61};
62use crate::common::{
63 consts::{BITMEX_WS_TOPIC_DELIMITER, BITMEX_WS_URL},
64 credential::{Credential, credential_env_vars},
65 enums::BitmexEnvironment,
66};
67
68#[derive(Clone, Debug)]
76pub struct BitmexWebSocketClient {
77 url: String,
78 credential: Option<Credential>,
79 heartbeat: Option<u64>,
80 account_id: AccountId,
81 auth_tracker: AuthTracker,
82 signal: Arc<AtomicBool>,
83 connection_mode: Arc<ArcSwap<AtomicU8>>,
84 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
85 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<BitmexWsMessage>>>,
86 task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
87 subscriptions: SubscriptionState,
88 tracked_subscriptions: Arc<DashMap<String, ()>>,
89 instruments: Arc<DashMap<Ustr, InstrumentAny>>,
90 transport_backend: TransportBackend,
91 proxy_url: Option<String>,
92}
93
94impl BitmexWebSocketClient {
95 pub fn new(
101 url: Option<String>,
102 api_key: Option<String>,
103 api_secret: Option<String>,
104 account_id: Option<AccountId>,
105 heartbeat: u64,
106 transport_backend: TransportBackend,
107 proxy_url: Option<String>,
108 ) -> anyhow::Result<Self> {
109 let credential = match (api_key, api_secret) {
110 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
111 (None, None) => None,
112 _ => anyhow::bail!("Both `api_key` and `api_secret` must be provided together"),
113 };
114
115 let account_id = account_id.unwrap_or(AccountId::from("BITMEX-master"));
116
117 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
118 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
119
120 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
122
123 Ok(Self {
124 url: url.unwrap_or(BITMEX_WS_URL.to_string()),
125 credential,
126 heartbeat: Some(heartbeat),
127 account_id,
128 auth_tracker: AuthTracker::new(),
129 signal: Arc::new(AtomicBool::new(false)),
130 connection_mode,
131 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
132 out_rx: None,
133 task_handle: None,
134 subscriptions: SubscriptionState::new(BITMEX_WS_TOPIC_DELIMITER),
135 tracked_subscriptions: Arc::new(DashMap::new()),
136 instruments: Arc::new(DashMap::new()),
137 transport_backend,
138 proxy_url,
139 })
140 }
141
142 #[expect(clippy::too_many_arguments)]
153 pub fn new_with_env(
154 url: Option<String>,
155 api_key: Option<String>,
156 api_secret: Option<String>,
157 account_id: Option<AccountId>,
158 heartbeat: u64,
159 environment: BitmexEnvironment,
160 transport_backend: TransportBackend,
161 proxy_url: Option<String>,
162 ) -> anyhow::Result<Self> {
163 let (api_key_env, api_secret_env) = credential_env_vars(environment);
164
165 let key = get_or_env_var_opt(api_key, api_key_env);
166 let secret = get_or_env_var_opt(api_secret, api_secret_env);
167
168 Self::new(
169 url,
170 key,
171 secret,
172 account_id,
173 heartbeat,
174 transport_backend,
175 proxy_url,
176 )
177 }
178
179 pub fn from_env() -> anyhow::Result<Self> {
185 let url = get_env_var("BITMEX_WS_URL")?;
186 let (key_var, secret_var) = credential_env_vars(BitmexEnvironment::Mainnet);
187 let api_key = get_env_var(key_var)?;
188 let api_secret = get_env_var(secret_var)?;
189
190 Self::new(
191 Some(url),
192 Some(api_key),
193 Some(api_secret),
194 None,
195 5,
196 TransportBackend::default(),
197 None,
198 )
199 }
200
201 #[must_use]
203 pub const fn url(&self) -> &str {
204 self.url.as_str()
205 }
206
207 #[must_use]
209 pub fn api_key(&self) -> Option<&str> {
210 self.credential.as_ref().map(|c| c.api_key())
211 }
212
213 #[must_use]
215 pub fn api_key_masked(&self) -> Option<String> {
216 self.credential.as_ref().map(|c| c.api_key_masked())
217 }
218
219 #[must_use]
221 pub fn is_active(&self) -> bool {
222 let connection_mode_arc = self.connection_mode.load();
223 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
224 && !self.signal.load(Ordering::Relaxed)
225 }
226
227 #[must_use]
229 pub fn is_closed(&self) -> bool {
230 let connection_mode_arc = self.connection_mode.load();
231 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
232 || self.signal.load(Ordering::Relaxed)
233 }
234
235 #[must_use]
237 pub fn account_id(&self) -> AccountId {
238 self.account_id
239 }
240
241 pub fn set_account_id(&mut self, account_id: AccountId) {
243 self.account_id = account_id;
244 }
245
246 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
248 self.instruments.clear();
249 for inst in instruments {
250 self.instruments
251 .insert(inst.raw_symbol().inner(), inst.clone());
252 }
253 }
254
255 pub fn cache_instrument(&self, instrument: InstrumentAny) {
257 self.instruments
258 .insert(instrument.raw_symbol().inner(), instrument);
259 }
260
261 #[must_use]
263 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
264 self.instruments
265 .get(symbol)
266 .map(|entry| entry.value().clone())
267 }
268
269 pub async fn connect(&mut self) -> Result<(), BitmexWsError> {
276 let (client, raw_rx) = self.connect_inner().await?;
277
278 self.signal.store(false, Ordering::Relaxed);
280
281 self.connection_mode.store(client.connection_mode_atomic());
283
284 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<BitmexWsMessage>();
285 self.out_rx = Some(Arc::new(out_rx));
286
287 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
288 *self.cmd_tx.write().await = cmd_tx.clone();
289
290 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
292 return Err(BitmexWsError::ClientError(format!(
293 "Failed to send WebSocketClient to handler: {e}"
294 )));
295 }
296
297 let signal = self.signal.clone();
298 let credential = self.credential.clone();
299 let auth_tracker = self.auth_tracker.clone();
300 let subscriptions = self.subscriptions.clone();
301 let cmd_tx_for_reconnect = cmd_tx.clone();
302
303 let stream_handle = get_runtime().spawn(async move {
304 let mut handler = BitmexWsFeedHandler::new(
305 signal.clone(),
306 cmd_rx,
307 raw_rx,
308 out_tx,
309 auth_tracker.clone(),
310 subscriptions.clone(),
311 );
312
313 let resubscribe_all = || {
315 let topics = subscriptions.all_topics();
317
318 if topics.is_empty() {
319 return;
320 }
321
322 log::debug!(
323 "Resubscribing to confirmed subscriptions: count={}",
324 topics.len()
325 );
326
327 for topic in &topics {
328 subscriptions.mark_subscribe(topic.as_str());
329 }
330
331 let mut payloads = Vec::with_capacity(topics.len());
333 for topic in &topics {
334 let message = BitmexSubscription {
335 op: BitmexWsOperation::Subscribe,
336 args: vec![Ustr::from(topic.as_ref())],
337 };
338
339 if let Ok(payload) = serde_json::to_string(&message) {
340 payloads.push(payload);
341 }
342 }
343
344 if let Err(e) =
345 cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { topics: payloads })
346 {
347 log::error!("Failed to send resubscribe command: {e}");
348 }
349 };
350
351 let mut waiting_for_reconnect_auth = false;
352
353 loop {
355 match handler.next().await {
356 Some(BitmexWsMessage::Reconnected) => {
357 if signal.load(Ordering::Relaxed) {
358 continue;
359 }
360
361 log::info!("WebSocket reconnected");
362
363 let confirmed_topics: Vec<String> = {
365 let confirmed = subscriptions.confirmed();
366 let mut topics = Vec::new();
367
368 for entry in confirmed.iter() {
369 let (channel, symbols) = entry.pair();
370
371 if *channel == BitmexWsTopic::Instrument.as_ref() {
372 continue;
373 }
374
375 for symbol in symbols {
376 if symbol.is_empty() {
377 topics.push(channel.to_string());
378 } else {
379 topics.push(format!("{channel}:{symbol}"));
380 }
381 }
382 }
383
384 topics
385 };
386
387 if !confirmed_topics.is_empty() {
388 log::debug!(
389 "Marking confirmed subscriptions as pending for replay: count={}",
390 confirmed_topics.len()
391 );
392
393 for topic in confirmed_topics {
394 subscriptions.mark_failure(&topic);
395 }
396 }
397
398 if let Some(cred) = &credential {
399 log::debug!("Re-authenticating after reconnection");
400 waiting_for_reconnect_auth = true;
401
402 let expires =
403 (chrono::Utc::now() + chrono::Duration::seconds(30)).timestamp();
404 let signature = cred.sign("GET", "/realtime", expires, "");
405
406 let auth_message = BitmexAuthentication {
407 op: BitmexWsAuthAction::AuthKeyExpires,
408 args: (cred.api_key().to_string(), expires, signature),
409 };
410
411 if let Ok(payload) = serde_json::to_string(&auth_message) {
412 if let Err(e) = cmd_tx_for_reconnect
413 .send(HandlerCommand::Authenticate { payload })
414 {
415 log::error!("Failed to send reconnection auth command: {e}");
416 }
417 } else {
418 log::error!("Failed to serialize reconnection auth message");
419 }
420 }
421
422 if credential.is_none() {
425 log::debug!("No authentication required, resubscribing immediately");
426 resubscribe_all();
427 }
428
429 if handler.send(BitmexWsMessage::Reconnected).is_err() {
430 if handler.is_stopped() {
431 log::debug!("Failed to forward reconnect event (receiver dropped)");
432 } else {
433 log::error!("Failed to forward reconnect event (receiver dropped)");
434 }
435 break;
436 }
437 }
438 Some(BitmexWsMessage::Authenticated) => {
439 if waiting_for_reconnect_auth {
440 log::debug!("Authenticated after reconnection, resubscribing");
441 resubscribe_all();
442 waiting_for_reconnect_auth = false;
443 }
444 }
445 Some(msg) => {
446 if handler.send(msg).is_err() {
447 if handler.is_stopped() {
448 log::debug!("Failed to send message (receiver dropped)");
449 } else {
450 log::error!("Failed to send message (receiver dropped)");
451 }
452 break;
453 }
454 }
455 None => {
456 if handler.is_stopped() {
458 log::debug!("Stop signal received, ending message processing");
459 break;
460 }
461 log::warn!("WebSocket stream ended unexpectedly");
463 break;
464 }
465 }
466 }
467
468 log::debug!("Handler task exiting");
469 });
470
471 self.task_handle = Some(Arc::new(stream_handle));
472
473 if self.credential.is_some()
474 && let Err(e) = self.authenticate().await
475 {
476 if let Some(handle) = self.task_handle.take() {
477 handle.abort();
478 }
479 self.signal.store(true, Ordering::Relaxed);
480 return Err(e);
481 }
482
483 let instrument_topic = BitmexWsTopic::Instrument.as_ref().to_string();
485 self.subscriptions.mark_subscribe(&instrument_topic);
486 self.tracked_subscriptions.insert(instrument_topic, ());
487
488 let subscribe_msg = BitmexSubscription {
489 op: BitmexWsOperation::Subscribe,
490 args: vec![Ustr::from(BitmexWsTopic::Instrument.as_ref())],
491 };
492
493 match serde_json::to_string(&subscribe_msg) {
494 Ok(subscribe_json) => {
495 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Subscribe {
496 topics: vec![subscribe_json],
497 }) {
498 log::error!("Failed to send subscribe command for instruments: {e}");
499 } else {
500 log::debug!("Subscribed to all instruments");
501 }
502 }
503 Err(e) => {
504 log::error!("Failed to serialize subscribe message: {e}");
505 }
506 }
507
508 Ok(())
509 }
510
511 async fn connect_inner(
517 &self,
518 ) -> Result<
519 (
520 WebSocketClient,
521 tokio::sync::mpsc::UnboundedReceiver<Message>,
522 ),
523 BitmexWsError,
524 > {
525 let (message_handler, rx) = channel_message_handler();
526
527 let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {
530 });
532
533 let config = WebSocketConfig {
534 url: self.url.clone(),
535 headers: vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())],
536 heartbeat: self.heartbeat,
537 heartbeat_msg: None,
538 reconnect_timeout_ms: Some(5_000),
539 reconnect_delay_initial_ms: None, reconnect_delay_max_ms: None, reconnect_backoff_factor: None, reconnect_jitter_ms: None, reconnect_max_attempts: None,
544 idle_timeout_ms: None,
545 backend: self.transport_backend,
546 proxy_url: self.proxy_url.clone(),
547 };
548
549 let keyed_quotas = vec![];
550 let client = WebSocketClient::connect(
551 config,
552 Some(message_handler),
553 Some(ping_handler),
554 None, keyed_quotas,
556 None, )
558 .await
559 .map_err(|e| BitmexWsError::ClientError(e.to_string()))?;
560
561 Ok((client, rx))
562 }
563
564 async fn authenticate(&self) -> Result<(), BitmexWsError> {
571 let credential = match &self.credential {
572 Some(credential) => credential,
573 None => {
574 return Err(BitmexWsError::AuthenticationError(
575 "API credentials not available to authenticate".to_string(),
576 ));
577 }
578 };
579
580 let receiver = self.auth_tracker.begin();
581
582 let expires = (chrono::Utc::now() + chrono::Duration::seconds(30)).timestamp();
583 let signature = credential.sign("GET", "/realtime", expires, "");
584
585 let auth_message = BitmexAuthentication {
586 op: BitmexWsAuthAction::AuthKeyExpires,
587 args: (credential.api_key().to_string(), expires, signature),
588 };
589
590 let auth_json = serde_json::to_string(&auth_message).map_err(|e| {
591 let msg = format!("Failed to serialize auth message: {e}");
592 self.auth_tracker.fail(msg.clone());
593 BitmexWsError::AuthenticationError(msg)
594 })?;
595
596 self.cmd_tx
598 .read()
599 .await
600 .send(HandlerCommand::Authenticate { payload: auth_json })
601 .map_err(|e| {
602 let msg = format!("Failed to send authenticate command: {e}");
603 self.auth_tracker.fail(msg.clone());
604 BitmexWsError::AuthenticationError(msg)
605 })?;
606
607 self.auth_tracker
608 .wait_for_result::<BitmexWsError>(
609 Duration::from_secs(AUTHENTICATION_TIMEOUT_SECS),
610 receiver,
611 )
612 .await
613 }
614
615 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), BitmexWsError> {
621 let timeout = Duration::from_secs_f64(timeout_secs);
622
623 tokio::time::timeout(timeout, async {
624 while !self.is_active() {
625 tokio::time::sleep(Duration::from_millis(10)).await;
626 }
627 })
628 .await
629 .map_err(|_| {
630 BitmexWsError::ClientError(format!(
631 "WebSocket connection timeout after {timeout_secs} seconds"
632 ))
633 })?;
634
635 Ok(())
636 }
637
638 pub fn stream(&mut self) -> impl Stream<Item = BitmexWsMessage> + use<> {
646 let rx = self
647 .out_rx
648 .take()
649 .expect("Stream receiver already taken or not connected");
650 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
651 async_stream::stream! {
652 while let Some(msg) = rx.recv().await {
653 yield msg;
654 }
655 }
656 }
657
658 pub async fn close(&mut self) -> Result<(), BitmexWsError> {
664 log::debug!("Starting close process");
665
666 self.signal.store(true, Ordering::Relaxed);
667
668 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
670 log::debug!(
671 "Failed to send disconnect command (handler may already be shut down): {e}"
672 );
673 }
674
675 if let Some(task_handle) = self.task_handle.take() {
677 match Arc::try_unwrap(task_handle) {
678 Ok(handle) => {
679 log::debug!("Waiting for task handle to complete");
680 match tokio::time::timeout(Duration::from_secs(2), handle).await {
681 Ok(Ok(())) => log::debug!("Task handle completed successfully"),
682 Ok(Err(e)) => log::error!("Task handle encountered an error: {e:?}"),
683 Err(_) => {
684 log::warn!(
685 "Timeout waiting for task handle, task may still be running"
686 );
687 }
689 }
690 }
691 Err(arc_handle) => {
692 log::debug!(
693 "Cannot take ownership of task handle - other references exist, aborting task"
694 );
695 arc_handle.abort();
696 }
697 }
698 } else {
699 log::debug!("No task handle to await");
700 }
701
702 log::debug!("Closed");
703
704 Ok(())
705 }
706
707 pub async fn subscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
713 log::debug!("Subscribing to topics: {topics:?}");
714
715 for topic in &topics {
716 self.subscriptions.mark_subscribe(topic.as_str());
717 self.tracked_subscriptions.insert(topic.clone(), ());
718 }
719
720 let mut payloads = Vec::with_capacity(topics.len());
722 for topic in &topics {
723 let message = BitmexSubscription {
724 op: BitmexWsOperation::Subscribe,
725 args: vec![Ustr::from(topic.as_ref())],
726 };
727 let payload = serde_json::to_string(&message).map_err(|e| {
728 BitmexWsError::SubscriptionError(format!("Failed to serialize subscription: {e}"))
729 })?;
730 payloads.push(payload);
731 }
732
733 let cmd = HandlerCommand::Subscribe { topics: payloads };
735
736 self.send_cmd(cmd).await.map_err(|e| {
737 BitmexWsError::SubscriptionError(format!("Failed to send subscribe command: {e}"))
738 })
739 }
740
741 async fn unsubscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
747 log::debug!("Attempting to unsubscribe from topics: {topics:?}");
748
749 if self.signal.load(Ordering::Relaxed) {
750 log::debug!("Shutdown signal detected, skipping unsubscribe");
751 return Ok(());
752 }
753
754 for topic in &topics {
755 self.subscriptions.mark_unsubscribe(topic.as_str());
756 self.tracked_subscriptions.remove(topic);
757 }
758
759 let mut payloads = Vec::with_capacity(topics.len());
761 for topic in &topics {
762 let message = BitmexSubscription {
763 op: BitmexWsOperation::Unsubscribe,
764 args: vec![Ustr::from(topic.as_ref())],
765 };
766
767 if let Ok(payload) = serde_json::to_string(&message) {
768 payloads.push(payload);
769 }
770 }
771
772 let cmd = HandlerCommand::Unsubscribe { topics: payloads };
774
775 if let Err(e) = self.send_cmd(cmd).await {
776 log::debug!("Failed to send unsubscribe command: {e}");
777 }
778
779 Ok(())
780 }
781
782 #[must_use]
784 pub fn subscription_count(&self) -> usize {
785 self.subscriptions.len()
786 }
787
788 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<String> {
789 let symbol = instrument_id.symbol.inner();
790 let confirmed = self.subscriptions.confirmed();
791 let mut channels = Vec::with_capacity(confirmed.len());
792
793 for entry in confirmed.iter() {
794 let (channel, symbols) = entry.pair();
795 if symbols.contains(&symbol) {
796 channels.push(format!("{channel}:{symbol}"));
798 } else {
799 let has_channel_marker = symbols.iter().any(|s| s.is_empty());
800 if has_channel_marker
801 && (*channel == BitmexWsAuthChannel::Execution.as_ref()
802 || *channel == BitmexWsAuthChannel::Order.as_ref())
803 {
804 channels.push(channel.to_string());
806 }
807 }
808 }
809
810 channels
811 }
812
813 pub async fn subscribe_instruments(&self) -> Result<(), BitmexWsError> {
819 log::debug!("Already subscribed to all instruments on connection, skipping");
821 Ok(())
822 }
823
824 pub async fn subscribe_instrument(
830 &self,
831 instrument_id: InstrumentId,
832 ) -> Result<(), BitmexWsError> {
833 log::debug!(
835 "Already subscribed to all instruments on connection (includes {instrument_id}), skipping"
836 );
837 Ok(())
838 }
839
840 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
846 let topic = BitmexWsTopic::OrderBookL2;
847 let symbol = instrument_id.symbol.inner();
848 self.subscribe(vec![format!("{topic}:{symbol}")]).await
849 }
850
851 pub async fn subscribe_book_25(
857 &self,
858 instrument_id: InstrumentId,
859 ) -> Result<(), BitmexWsError> {
860 let topic = BitmexWsTopic::OrderBookL2_25;
861 let symbol = instrument_id.symbol.inner();
862 self.subscribe(vec![format!("{topic}:{symbol}")]).await
863 }
864
865 pub async fn subscribe_book_depth10(
871 &self,
872 instrument_id: InstrumentId,
873 ) -> Result<(), BitmexWsError> {
874 let topic = BitmexWsTopic::OrderBook10;
875 let symbol = instrument_id.symbol.inner();
876 self.subscribe(vec![format!("{topic}:{symbol}")]).await
877 }
878
879 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
887 let symbol = instrument_id.symbol.inner();
888
889 if is_index_symbol(&instrument_id.symbol.inner()) {
891 log::warn!("Ignoring quote subscription for index symbol: {symbol}");
892 return Ok(());
893 }
894
895 let topic = BitmexWsTopic::Quote;
896 self.subscribe(vec![format!("{topic}:{symbol}")]).await
897 }
898
899 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
907 let symbol = instrument_id.symbol.inner();
908
909 if is_index_symbol(&symbol) {
911 log::warn!("Ignoring trade subscription for index symbol: {symbol}");
912 return Ok(());
913 }
914
915 let topic = BitmexWsTopic::Trade;
916 self.subscribe(vec![format!("{topic}:{symbol}")]).await
917 }
918
919 pub async fn subscribe_mark_prices(
925 &self,
926 instrument_id: InstrumentId,
927 ) -> Result<(), BitmexWsError> {
928 self.subscribe_instrument(instrument_id).await
929 }
930
931 pub async fn subscribe_index_prices(
937 &self,
938 instrument_id: InstrumentId,
939 ) -> Result<(), BitmexWsError> {
940 self.subscribe_instrument(instrument_id).await
941 }
942
943 pub async fn subscribe_funding_rates(
949 &self,
950 instrument_id: InstrumentId,
951 ) -> Result<(), BitmexWsError> {
952 let topic = BitmexWsTopic::Funding;
953 let symbol = instrument_id.symbol.inner();
954 self.subscribe(vec![format!("{topic}:{symbol}")]).await
955 }
956
957 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
963 let topic = topic_from_bar_spec(bar_type.spec());
964 let symbol = bar_type.instrument_id().symbol.inner();
965 self.subscribe(vec![format!("{topic}:{symbol}")]).await
966 }
967
968 pub async fn unsubscribe_instruments(&self) -> Result<(), BitmexWsError> {
974 log::debug!(
976 "Instruments subscription maintained for proper operation, skipping unsubscribe"
977 );
978 Ok(())
979 }
980
981 pub async fn unsubscribe_instrument(
987 &self,
988 instrument_id: InstrumentId,
989 ) -> Result<(), BitmexWsError> {
990 log::debug!(
992 "Instruments subscription maintained for proper operation (includes {instrument_id}), skipping unsubscribe"
993 );
994 Ok(())
995 }
996
997 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
1003 let topic = BitmexWsTopic::OrderBookL2;
1004 let symbol = instrument_id.symbol.inner();
1005 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1006 }
1007
1008 pub async fn unsubscribe_book_25(
1014 &self,
1015 instrument_id: InstrumentId,
1016 ) -> Result<(), BitmexWsError> {
1017 let topic = BitmexWsTopic::OrderBookL2_25;
1018 let symbol = instrument_id.symbol.inner();
1019 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1020 }
1021
1022 pub async fn unsubscribe_book_depth10(
1028 &self,
1029 instrument_id: InstrumentId,
1030 ) -> Result<(), BitmexWsError> {
1031 let topic = BitmexWsTopic::OrderBook10;
1032 let symbol = instrument_id.symbol.inner();
1033 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1034 }
1035
1036 pub async fn unsubscribe_quotes(
1042 &self,
1043 instrument_id: InstrumentId,
1044 ) -> Result<(), BitmexWsError> {
1045 let symbol = instrument_id.symbol.inner();
1046
1047 if is_index_symbol(&symbol) {
1049 return Ok(());
1050 }
1051
1052 let topic = BitmexWsTopic::Quote;
1053 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1054 }
1055
1056 pub async fn unsubscribe_trades(
1062 &self,
1063 instrument_id: InstrumentId,
1064 ) -> Result<(), BitmexWsError> {
1065 let symbol = instrument_id.symbol.inner();
1066
1067 if is_index_symbol(&symbol) {
1069 return Ok(());
1070 }
1071
1072 let topic = BitmexWsTopic::Trade;
1073 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1074 }
1075
1076 pub async fn unsubscribe_mark_prices(
1082 &self,
1083 instrument_id: InstrumentId,
1084 ) -> Result<(), BitmexWsError> {
1085 log::debug!(
1087 "Mark prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1088 );
1089 Ok(())
1090 }
1091
1092 pub async fn unsubscribe_index_prices(
1098 &self,
1099 instrument_id: InstrumentId,
1100 ) -> Result<(), BitmexWsError> {
1101 log::debug!(
1103 "Index prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1104 );
1105 Ok(())
1106 }
1107
1108 pub async fn unsubscribe_funding_rates(
1114 &self,
1115 instrument_id: InstrumentId,
1116 ) -> Result<(), BitmexWsError> {
1117 log::debug!(
1119 "Funding rates for {instrument_id}, skipping unsubscribe to avoid shutdown race"
1120 );
1121 Ok(())
1122 }
1123
1124 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
1130 let topic = topic_from_bar_spec(bar_type.spec());
1131 let symbol = bar_type.instrument_id().symbol.inner();
1132 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1133 }
1134
1135 pub async fn subscribe_orders(&self) -> Result<(), BitmexWsError> {
1141 if self.credential.is_none() {
1142 return Err(BitmexWsError::MissingCredentials);
1143 }
1144 self.subscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1145 .await
1146 }
1147
1148 pub async fn subscribe_executions(&self) -> Result<(), BitmexWsError> {
1154 if self.credential.is_none() {
1155 return Err(BitmexWsError::MissingCredentials);
1156 }
1157 self.subscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1158 .await
1159 }
1160
1161 pub async fn subscribe_positions(&self) -> Result<(), BitmexWsError> {
1167 if self.credential.is_none() {
1168 return Err(BitmexWsError::MissingCredentials);
1169 }
1170 self.subscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1171 .await
1172 }
1173
1174 pub async fn subscribe_margin(&self) -> Result<(), BitmexWsError> {
1180 if self.credential.is_none() {
1181 return Err(BitmexWsError::MissingCredentials);
1182 }
1183 self.subscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1184 .await
1185 }
1186
1187 pub async fn subscribe_wallet(&self) -> Result<(), BitmexWsError> {
1193 if self.credential.is_none() {
1194 return Err(BitmexWsError::MissingCredentials);
1195 }
1196 self.subscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1197 .await
1198 }
1199
1200 pub async fn unsubscribe_orders(&self) -> Result<(), BitmexWsError> {
1206 self.unsubscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1207 .await
1208 }
1209
1210 pub async fn unsubscribe_executions(&self) -> Result<(), BitmexWsError> {
1216 self.unsubscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1217 .await
1218 }
1219
1220 pub async fn unsubscribe_positions(&self) -> Result<(), BitmexWsError> {
1226 self.unsubscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1227 .await
1228 }
1229
1230 pub async fn unsubscribe_margin(&self) -> Result<(), BitmexWsError> {
1236 self.unsubscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1237 .await
1238 }
1239
1240 pub async fn unsubscribe_wallet(&self) -> Result<(), BitmexWsError> {
1246 self.unsubscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1247 .await
1248 }
1249
1250 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), BitmexWsError> {
1252 self.cmd_tx
1253 .read()
1254 .await
1255 .send(cmd)
1256 .map_err(|e| BitmexWsError::ClientError(format!("Handler not available: {e}")))
1257 }
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262 use ahash::AHashSet;
1263 use rstest::rstest;
1264 use ustr::Ustr;
1265
1266 use super::*;
1267
1268 #[rstest]
1269 fn test_reconnect_topics_restoration_logic() {
1270 let client = BitmexWebSocketClient::new(
1272 Some("ws://test.com".to_string()),
1273 Some("test_key".to_string()),
1274 Some("test_secret".to_string()),
1275 Some(AccountId::new("BITMEX-TEST")),
1276 5,
1277 TransportBackend::default(),
1278 None,
1279 )
1280 .unwrap();
1281
1282 let subs = client.subscriptions.confirmed();
1284 subs.insert(Ustr::from(BitmexWsTopic::Trade.as_ref()), {
1285 let mut set = AHashSet::new();
1286 set.insert(Ustr::from("XBTUSD"));
1287 set.insert(Ustr::from("ETHUSD"));
1288 set
1289 });
1290
1291 subs.insert(Ustr::from(BitmexWsTopic::OrderBookL2.as_ref()), {
1292 let mut set = AHashSet::new();
1293 set.insert(Ustr::from("XBTUSD"));
1294 set
1295 });
1296
1297 subs.insert(Ustr::from(BitmexWsAuthChannel::Order.as_ref()), {
1299 let mut set = AHashSet::new();
1300 set.insert(Ustr::from(""));
1301 set
1302 });
1303 subs.insert(Ustr::from(BitmexWsAuthChannel::Position.as_ref()), {
1304 let mut set = AHashSet::new();
1305 set.insert(Ustr::from(""));
1306 set
1307 });
1308
1309 let mut topics_to_restore = Vec::new();
1311
1312 for entry in subs.iter() {
1313 let (channel, symbols) = entry.pair();
1314 for symbol in symbols {
1315 if symbol.is_empty() {
1316 topics_to_restore.push(channel.to_string());
1317 } else {
1318 topics_to_restore.push(format!("{channel}:{symbol}"));
1319 }
1320 }
1321 }
1322
1323 assert!(topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref())));
1325 assert!(topics_to_restore.contains(&format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref())));
1326 assert!(
1327 topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()))
1328 );
1329 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Order.as_ref().to_string()));
1330 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Position.as_ref().to_string()));
1331 assert_eq!(topics_to_restore.len(), 5);
1332 }
1333
1334 #[rstest]
1335 fn test_reconnect_auth_message_building() {
1336 let client_with_creds = BitmexWebSocketClient::new(
1338 Some("ws://test.com".to_string()),
1339 Some("test_key".to_string()),
1340 Some("test_secret".to_string()),
1341 Some(AccountId::new("BITMEX-TEST")),
1342 5,
1343 TransportBackend::default(),
1344 None,
1345 )
1346 .unwrap();
1347
1348 if let Some(cred) = &client_with_creds.credential {
1350 let expires = (chrono::Utc::now() + chrono::Duration::seconds(30)).timestamp();
1351 let signature = cred.sign("GET", "/realtime", expires, "");
1352
1353 let auth_message = BitmexAuthentication {
1354 op: BitmexWsAuthAction::AuthKeyExpires,
1355 args: (cred.api_key().to_string(), expires, signature),
1356 };
1357
1358 assert_eq!(auth_message.op, BitmexWsAuthAction::AuthKeyExpires);
1360 assert_eq!(auth_message.args.0, "test_key");
1361 assert!(auth_message.args.1 > 0); assert!(!auth_message.args.2.is_empty()); } else {
1364 panic!("Client should have credentials");
1365 }
1366
1367 let client_no_creds = BitmexWebSocketClient::new(
1369 Some("ws://test.com".to_string()),
1370 None,
1371 None,
1372 Some(AccountId::new("BITMEX-TEST")),
1373 5,
1374 TransportBackend::default(),
1375 None,
1376 )
1377 .unwrap();
1378
1379 assert!(client_no_creds.credential.is_none());
1380 }
1381
1382 #[rstest]
1383 fn test_subscription_state_after_unsubscribe() {
1384 let client = BitmexWebSocketClient::new(
1385 Some("ws://test.com".to_string()),
1386 Some("test_key".to_string()),
1387 Some("test_secret".to_string()),
1388 Some(AccountId::new("BITMEX-TEST")),
1389 5,
1390 TransportBackend::default(),
1391 None,
1392 )
1393 .unwrap();
1394
1395 let subs = client.subscriptions.confirmed();
1397 subs.insert(Ustr::from(BitmexWsTopic::Trade.as_ref()), {
1398 let mut set = AHashSet::new();
1399 set.insert(Ustr::from("XBTUSD"));
1400 set.insert(Ustr::from("ETHUSD"));
1401 set
1402 });
1403
1404 subs.insert(Ustr::from(BitmexWsTopic::OrderBookL2.as_ref()), {
1405 let mut set = AHashSet::new();
1406 set.insert(Ustr::from("XBTUSD"));
1407 set
1408 });
1409
1410 let topic = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1412 if let Some((channel, symbol)) = topic.split_once(':')
1413 && let Some(mut entry) = subs.get_mut(&Ustr::from(channel))
1414 {
1415 entry.remove(&Ustr::from(symbol));
1416 if entry.is_empty() {
1417 drop(entry);
1418 subs.remove(&Ustr::from(channel));
1419 }
1420 }
1421
1422 let mut topics_to_restore = Vec::new();
1424
1425 for entry in subs.iter() {
1426 let (channel, symbols) = entry.pair();
1427 for symbol in symbols {
1428 if symbol.is_empty() {
1429 topics_to_restore.push(channel.to_string());
1430 } else {
1431 topics_to_restore.push(format!("{channel}:{symbol}"));
1432 }
1433 }
1434 }
1435
1436 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1438 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1439 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1440
1441 assert!(topics_to_restore.contains(&trade_xbt));
1442 assert!(!topics_to_restore.contains(&trade_eth));
1443 assert!(topics_to_restore.contains(&book_xbt));
1444 assert_eq!(topics_to_restore.len(), 2);
1445 }
1446
1447 #[rstest]
1448 fn test_race_unsubscribe_failure_recovery() {
1449 let client = BitmexWebSocketClient::new(
1455 Some("ws://test.com".to_string()),
1456 None,
1457 None,
1458 Some(AccountId::new("BITMEX-TEST")),
1459 5,
1460 TransportBackend::default(),
1461 None,
1462 )
1463 .unwrap();
1464
1465 let topic = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1466
1467 client.subscriptions.mark_subscribe(&topic);
1469 client.subscriptions.confirm_subscribe(&topic);
1470 assert_eq!(client.subscriptions.len(), 1);
1471
1472 client.subscriptions.mark_unsubscribe(&topic);
1474 assert_eq!(client.subscriptions.len(), 0);
1475 assert_eq!(
1476 client.subscriptions.pending_unsubscribe_topics(),
1477 vec![topic.clone()]
1478 );
1479
1480 client.subscriptions.confirm_unsubscribe(&topic); client.subscriptions.mark_subscribe(&topic); client.subscriptions.confirm_subscribe(&topic); assert_eq!(client.subscriptions.len(), 1);
1488 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1489 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1490
1491 let all = client.subscriptions.all_topics();
1493 assert_eq!(all.len(), 1);
1494 assert!(all.contains(&topic));
1495 }
1496
1497 #[rstest]
1498 fn test_race_resubscribe_before_unsubscribe_ack() {
1499 let client = BitmexWebSocketClient::new(
1503 Some("ws://test.com".to_string()),
1504 None,
1505 None,
1506 Some(AccountId::new("BITMEX-TEST")),
1507 5,
1508 TransportBackend::default(),
1509 None,
1510 )
1511 .unwrap();
1512
1513 let topic = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1514
1515 client.subscriptions.mark_subscribe(&topic);
1517 client.subscriptions.confirm_subscribe(&topic);
1518 assert_eq!(client.subscriptions.len(), 1);
1519
1520 client.subscriptions.mark_unsubscribe(&topic);
1522 assert_eq!(client.subscriptions.len(), 0);
1523 assert_eq!(
1524 client.subscriptions.pending_unsubscribe_topics(),
1525 vec![topic.clone()]
1526 );
1527
1528 client.subscriptions.mark_subscribe(&topic);
1530 assert_eq!(
1531 client.subscriptions.pending_subscribe_topics(),
1532 vec![topic.clone()]
1533 );
1534
1535 client.subscriptions.confirm_unsubscribe(&topic);
1537 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1538 assert_eq!(
1539 client.subscriptions.pending_subscribe_topics(),
1540 vec![topic.clone()]
1541 );
1542
1543 client.subscriptions.confirm_subscribe(&topic);
1545 assert_eq!(client.subscriptions.len(), 1);
1546 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1547
1548 let all = client.subscriptions.all_topics();
1550 assert_eq!(all.len(), 1);
1551 assert!(all.contains(&topic));
1552 }
1553
1554 #[rstest]
1555 fn test_race_channel_level_reconnection_with_pending_states() {
1556 let client = BitmexWebSocketClient::new(
1558 Some("ws://test.com".to_string()),
1559 Some("test_key".to_string()),
1560 Some("test_secret".to_string()),
1561 Some(AccountId::new("BITMEX-TEST")),
1562 5,
1563 TransportBackend::default(),
1564 None,
1565 )
1566 .unwrap();
1567
1568 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1571 client.subscriptions.mark_subscribe(&trade_xbt);
1572 client.subscriptions.confirm_subscribe(&trade_xbt);
1573
1574 let order_channel = BitmexWsAuthChannel::Order.as_ref();
1576 client.subscriptions.mark_subscribe(order_channel);
1577 client.subscriptions.confirm_subscribe(order_channel);
1578
1579 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1581 client.subscriptions.mark_subscribe(&trade_eth);
1582
1583 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1585 client.subscriptions.mark_subscribe(&book_xbt);
1586 client.subscriptions.confirm_subscribe(&book_xbt);
1587 client.subscriptions.mark_unsubscribe(&book_xbt);
1588
1589 let topics_to_restore = client.subscriptions.all_topics();
1591
1592 assert_eq!(topics_to_restore.len(), 3);
1594 assert!(topics_to_restore.contains(&trade_xbt));
1595 assert!(topics_to_restore.contains(&order_channel.to_string()));
1596 assert!(topics_to_restore.contains(&trade_eth));
1597 assert!(!topics_to_restore.contains(&book_xbt)); for topic in &topics_to_restore {
1602 if topic == order_channel {
1603 assert!(
1604 !topic.contains(':'),
1605 "Channel-level topic should not have delimiter"
1606 );
1607 }
1608 }
1609 }
1610}