1use std::{
25 sync::{
26 Arc,
27 atomic::{AtomicBool, AtomicU8, Ordering},
28 },
29 time::Duration,
30};
31
32use arc_swap::ArcSwap;
33use dashmap::DashMap;
34use futures_util::Stream;
35use nautilus_common::live::get_runtime;
36use nautilus_core::{
37 consts::NAUTILUS_USER_AGENT,
38 env::{get_env_var, get_or_env_var_opt},
39 string::secret::SecretString,
40};
41use nautilus_live::SocketControl;
42use nautilus_model::{
43 data::bar::BarType,
44 identifiers::{AccountId, InstrumentId},
45 instruments::{Instrument, InstrumentAny},
46};
47use nautilus_network::{
48 http::USER_AGENT,
49 mode::ConnectionMode,
50 websocket::{
51 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, SubscriptionState, TransportBackend,
52 WebSocketClient, WebSocketConfig, channel_message_handler,
53 },
54};
55use tokio_tungstenite::tungstenite::Message;
56use ustr::Ustr;
57use zeroize::Zeroizing;
58
59use super::{
60 enums::{BitmexWsAuthAction, BitmexWsAuthChannel, BitmexWsOperation, BitmexWsTopic},
61 error::BitmexWsError,
62 handler::{BitmexWsFeedHandler, HandlerCommand},
63 messages::{BitmexAuthentication, BitmexSubscription, BitmexWsMessage},
64 parse::{is_index_symbol, topic_from_bar_spec},
65};
66use crate::common::{
67 consts::{BITMEX_WS_TOPIC_DELIMITER, BITMEX_WS_URL},
68 credential::{Credential, credential_env_vars},
69 enums::BitmexEnvironment,
70};
71
72#[derive(Debug, Clone)]
81pub struct BitmexWebSocketClient {
82 url: String,
83 credential: Option<Credential>,
84 heartbeat: Option<u64>,
85 auth_timeout_secs: u64,
86 account_id: AccountId,
87 auth_tracker: AuthTracker,
88 signal: Arc<AtomicBool>,
89 connection_mode: Arc<ArcSwap<AtomicU8>>,
90 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
91 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<BitmexWsMessage>>>,
92 task_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
93 subscriptions: SubscriptionState,
94 tracked_subscriptions: Arc<DashMap<String, ()>>,
95 instruments: Arc<DashMap<Ustr, InstrumentAny>>,
96 transport_backend: TransportBackend,
97 proxy_url: Option<SecretString>,
98 socket_control: Option<SocketControl>,
99}
100
101impl BitmexWebSocketClient {
102 #[expect(clippy::too_many_arguments)]
108 pub fn new(
109 url: Option<String>,
110 api_key: Option<String>,
111 api_secret: Option<String>,
112 account_id: Option<AccountId>,
113 heartbeat: u64,
114 auth_timeout_secs: Option<u64>,
115 transport_backend: TransportBackend,
116 proxy_url: Option<String>,
117 ) -> anyhow::Result<Self> {
118 let credential = match (api_key, api_secret) {
119 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
120 (None, None) => None,
121 _ => anyhow::bail!("Both `api_key` and `api_secret` must be provided together"),
122 };
123
124 let account_id = account_id.unwrap_or(AccountId::from("BITMEX-master"));
125
126 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
127 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
128
129 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
131
132 Ok(Self {
133 url: url.unwrap_or(BITMEX_WS_URL.to_string()),
134 credential,
135 heartbeat: Some(heartbeat),
136 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
137 account_id,
138 auth_tracker: AuthTracker::new(),
139 signal: Arc::new(AtomicBool::new(false)),
140 connection_mode,
141 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
142 out_rx: None,
143 task_handle: None,
144 subscriptions: SubscriptionState::new(BITMEX_WS_TOPIC_DELIMITER),
145 tracked_subscriptions: Arc::new(DashMap::new()),
146 instruments: Arc::new(DashMap::new()),
147 transport_backend,
148 proxy_url: proxy_url.map(SecretString::from),
149 socket_control: None,
150 })
151 }
152
153 #[must_use]
155 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
156 self.socket_control = Some(control);
157 self
158 }
159
160 #[expect(clippy::too_many_arguments)]
171 pub fn new_with_env(
172 url: Option<String>,
173 api_key: Option<String>,
174 api_secret: Option<String>,
175 account_id: Option<AccountId>,
176 heartbeat: u64,
177 auth_timeout_secs: Option<u64>,
178 environment: BitmexEnvironment,
179 transport_backend: TransportBackend,
180 proxy_url: Option<String>,
181 ) -> anyhow::Result<Self> {
182 let (api_key_env, api_secret_env) = credential_env_vars(environment);
183
184 let key = get_or_env_var_opt(api_key, api_key_env);
185 let secret = get_or_env_var_opt(api_secret, api_secret_env);
186
187 Self::new(
188 url,
189 key,
190 secret,
191 account_id,
192 heartbeat,
193 auth_timeout_secs,
194 transport_backend,
195 proxy_url,
196 )
197 }
198
199 pub fn from_env() -> anyhow::Result<Self> {
205 let url = get_env_var("BITMEX_WS_URL")?;
206 let (key_var, secret_var) = credential_env_vars(BitmexEnvironment::Mainnet);
207 let api_key = get_env_var(key_var)?;
208 let api_secret = get_env_var(secret_var)?;
209
210 Self::new(
211 Some(url),
212 Some(api_key),
213 Some(api_secret),
214 None,
215 5,
216 None,
217 TransportBackend::default(),
218 None,
219 )
220 }
221
222 #[must_use]
224 pub const fn url(&self) -> &str {
225 self.url.as_str()
226 }
227
228 #[must_use]
230 pub fn api_key(&self) -> Option<&str> {
231 self.credential.as_ref().map(|c| c.api_key())
232 }
233
234 #[must_use]
236 pub fn api_key_masked(&self) -> Option<String> {
237 self.credential.as_ref().map(|c| c.api_key_masked())
238 }
239
240 #[must_use]
242 pub fn is_active(&self) -> bool {
243 let connection_mode_arc = self.connection_mode.load();
244 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
245 && !self.signal.load(Ordering::Relaxed)
246 }
247
248 #[must_use]
250 pub fn is_closed(&self) -> bool {
251 let connection_mode_arc = self.connection_mode.load();
252 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
253 || self.signal.load(Ordering::Relaxed)
254 }
255
256 #[must_use]
258 pub fn account_id(&self) -> AccountId {
259 self.account_id
260 }
261
262 pub fn set_account_id(&mut self, account_id: AccountId) {
264 self.account_id = account_id;
265 }
266
267 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
269 self.instruments.clear();
270 for inst in instruments {
271 self.instruments
272 .insert(inst.raw_symbol().inner(), inst.clone());
273 }
274 }
275
276 pub fn cache_instrument(&self, instrument: InstrumentAny) {
278 self.instruments
279 .insert(instrument.raw_symbol().inner(), instrument);
280 }
281
282 #[must_use]
284 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
285 self.instruments
286 .get(symbol)
287 .map(|entry| entry.value().clone())
288 }
289
290 pub async fn connect(&mut self) -> Result<(), BitmexWsError> {
296 let (client, raw_rx) = self.connect_inner().await?;
297
298 self.signal.store(false, Ordering::Relaxed);
300
301 self.connection_mode.store(client.connection_mode_atomic());
303 let reconnect_handle = client.reconnect_handle();
304
305 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<BitmexWsMessage>();
306 self.out_rx = Some(Arc::new(out_rx));
307
308 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
309 *self.cmd_tx.write().await = cmd_tx.clone();
310
311 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
313 return Err(BitmexWsError::ClientError(format!(
314 "Failed to send WebSocketClient to handler: {e}"
315 )));
316 }
317
318 if let Some(control) = &self.socket_control {
319 control.register(move || reconnect_handle.request_reconnect());
320 }
321
322 let signal = self.signal.clone();
323 let credential = self.credential.clone();
324 let auth_tracker = self.auth_tracker.clone();
325 let subscriptions = self.subscriptions.clone();
326 let cmd_tx_for_reconnect = cmd_tx.clone();
327
328 let stream_handle = get_runtime().spawn(async move {
329 let mut handler = BitmexWsFeedHandler::new(
330 signal.clone(),
331 cmd_rx,
332 raw_rx,
333 out_tx,
334 auth_tracker.clone(),
335 subscriptions.clone(),
336 );
337
338 let resubscribe_all = || {
340 let topics = subscriptions.all_topics();
342
343 if topics.is_empty() {
344 return;
345 }
346
347 log::debug!(
348 "Resubscribing to confirmed subscriptions: count={}",
349 topics.len()
350 );
351
352 for topic in &topics {
353 subscriptions.mark_subscribe(topic.as_str());
354 }
355
356 let mut payloads = Vec::with_capacity(topics.len());
358 for topic in &topics {
359 let message = BitmexSubscription {
360 op: BitmexWsOperation::Subscribe,
361 args: vec![Ustr::from(topic.as_ref())],
362 };
363
364 if let Ok(payload) = serde_json::to_string(&message) {
365 payloads.push(payload);
366 }
367 }
368
369 if let Err(e) =
370 cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { topics: payloads })
371 {
372 log::error!("Failed to send resubscribe command: {e}");
373 }
374 };
375
376 let mut waiting_for_reconnect_auth = false;
377
378 loop {
380 match handler.next().await {
381 Some(BitmexWsMessage::Reconnected) => {
382 if signal.load(Ordering::Relaxed) {
383 continue;
384 }
385
386 log::info!("WebSocket reconnected");
387
388 subscriptions.reset_after_reconnect();
389
390 if let Some(cred) = &credential {
391 log::debug!("Re-authenticating after reconnection");
392 waiting_for_reconnect_auth = true;
393
394 let expires = (jiff::Timestamp::now()
395 + jiff::SignedDuration::from_secs(30))
396 .as_second();
397 let signature = cred.sign("GET", "/realtime", expires, "");
398
399 let auth_message = Zeroizing::new(BitmexAuthentication {
400 op: BitmexWsAuthAction::AuthKeyExpires,
401 args: (cred.api_key().to_string(), expires, signature),
402 });
403
404 if let Ok(payload) =
405 serde_json::to_string(&*auth_message).map(SecretString::from)
406 {
407 if let Err(e) = cmd_tx_for_reconnect
408 .send(HandlerCommand::Authenticate { payload })
409 {
410 log::error!("Failed to send reconnection auth command: {e}");
411 }
412 } else {
413 log::error!("Failed to serialize reconnection auth message");
414 }
415 }
416
417 if credential.is_none() {
420 log::debug!("No authentication required, resubscribing immediately");
421 resubscribe_all();
422 }
423
424 if handler.send(BitmexWsMessage::Reconnected).is_err() {
425 if handler.is_stopped() {
426 log::debug!("Failed to forward reconnect event (receiver dropped)");
427 } else {
428 log::error!("Failed to forward reconnect event (receiver dropped)");
429 }
430 break;
431 }
432 }
433 Some(BitmexWsMessage::Authenticated) => {
434 if waiting_for_reconnect_auth {
435 log::debug!("Authenticated after reconnection, resubscribing");
436 resubscribe_all();
437 waiting_for_reconnect_auth = false;
438 }
439 }
440 Some(msg) => {
441 if handler.send(msg).is_err() {
442 if handler.is_stopped() {
443 log::debug!("Failed to send message (receiver dropped)");
444 } else {
445 log::error!("Failed to send message (receiver dropped)");
446 }
447 break;
448 }
449 }
450 None => {
451 if handler.is_stopped() {
453 log::debug!("Stop signal received, ending message processing");
454 break;
455 }
456 log::warn!("WebSocket stream ended unexpectedly");
458 break;
459 }
460 }
461 }
462
463 log::debug!("Handler task exiting");
464 });
465
466 self.task_handle = Some(Arc::new(stream_handle));
467
468 if self.credential.is_some()
469 && let Err(e) = self.authenticate().await
470 {
471 if let Some(handle) = self.task_handle.take() {
472 handle.abort();
473 }
474 self.signal.store(true, Ordering::Relaxed);
475 return Err(e);
476 }
477
478 let instrument_topic = BitmexWsTopic::Instrument.as_ref().to_string();
480 self.subscriptions.mark_subscribe(&instrument_topic);
481 self.tracked_subscriptions.insert(instrument_topic, ());
482
483 let subscribe_msg = BitmexSubscription {
484 op: BitmexWsOperation::Subscribe,
485 args: vec![Ustr::from(BitmexWsTopic::Instrument.as_ref())],
486 };
487
488 match serde_json::to_string(&subscribe_msg) {
489 Ok(subscribe_json) => {
490 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Subscribe {
491 topics: vec![subscribe_json],
492 }) {
493 log::error!("Failed to send subscribe command for instruments: {e}");
494 } else {
495 log::debug!("Subscribed to all instruments");
496 }
497 }
498 Err(e) => {
499 log::error!("Failed to serialize subscribe message: {e}");
500 }
501 }
502
503 Ok(())
504 }
505
506 async fn connect_inner(
512 &self,
513 ) -> Result<
514 (
515 WebSocketClient,
516 tokio::sync::mpsc::UnboundedReceiver<Message>,
517 ),
518 BitmexWsError,
519 > {
520 let (message_handler, rx) = channel_message_handler();
521
522 let config = WebSocketConfig {
526 url: self.url.clone(),
527 headers: vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())],
528 heartbeat_interval_secs: self.heartbeat,
529 heartbeat_payload: None,
530 connect_timeout_ms: Some(5_000),
531 reconnect_delay_initial_ms: None, reconnect_delay_max_ms: None, reconnect_backoff_factor: None, reconnect_jitter_ms: None, reconnect_max_attempts: None,
536 heartbeat_timeout_secs: None,
537 idle_timeout_ms: None,
538 backend: self.transport_backend,
539 proxy_url: self
540 .proxy_url
541 .as_ref()
542 .map(|value| value.expose_secret().to_owned()),
543 };
544
545 let keyed_quotas = vec![];
546 let client = WebSocketClient::builder()
547 .config(config)
548 .message_handler(message_handler)
549 .keyed_quotas(keyed_quotas)
550 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
551 .connect()
552 .await
553 .map_err(|e| BitmexWsError::ClientError(e.to_string()))?;
554
555 Ok((client, rx))
556 }
557
558 async fn authenticate(&self) -> Result<(), BitmexWsError> {
565 let credential = match &self.credential {
566 Some(credential) => credential,
567 None => {
568 return Err(BitmexWsError::AuthenticationError(
569 "API credentials not available to authenticate".to_string(),
570 ));
571 }
572 };
573
574 let receiver = self.auth_tracker.begin();
575
576 let expires = (jiff::Timestamp::now() + jiff::SignedDuration::from_secs(30)).as_second();
577 let signature = credential.sign("GET", "/realtime", expires, "");
578
579 let auth_message = Zeroizing::new(BitmexAuthentication {
580 op: BitmexWsAuthAction::AuthKeyExpires,
581 args: (credential.api_key().to_string(), expires, signature),
582 });
583
584 let auth_json = serde_json::to_string(&*auth_message)
585 .map(SecretString::from)
586 .map_err(|e| {
587 let msg = format!("Failed to serialize auth message: {e}");
588 self.auth_tracker.fail(msg.clone());
589 BitmexWsError::AuthenticationError(msg)
590 })?;
591 drop(auth_message);
592
593 self.cmd_tx
595 .read()
596 .await
597 .send(HandlerCommand::Authenticate { payload: auth_json })
598 .map_err(|e| {
599 let msg = format!("Failed to send authenticate command: {e}");
600 self.auth_tracker.fail(msg.clone());
601 BitmexWsError::AuthenticationError(msg)
602 })?;
603
604 self.auth_tracker
605 .wait_for_result::<BitmexWsError>(Duration::from_secs(self.auth_timeout_secs), receiver)
606 .await
607 }
608
609 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), BitmexWsError> {
615 let timeout = Duration::from_secs_f64(timeout_secs);
616
617 tokio::time::timeout(timeout, async {
618 while !self.is_active() {
619 tokio::time::sleep(Duration::from_millis(10)).await;
620 }
621 })
622 .await
623 .map_err(|_| {
624 BitmexWsError::ClientError(format!(
625 "WebSocket connection timeout after {timeout_secs} seconds"
626 ))
627 })?;
628
629 Ok(())
630 }
631
632 pub fn stream(&mut self) -> impl Stream<Item = BitmexWsMessage> + use<> {
640 let rx = self
641 .out_rx
642 .take()
643 .expect("Stream receiver already taken or not connected");
644 let mut rx = Arc::try_unwrap(rx).expect("Cannot take ownership - other references exist");
645 async_stream::stream! {
646 while let Some(msg) = rx.recv().await {
647 yield msg;
648 }
649 }
650 }
651
652 pub async fn close(&mut self) -> Result<(), BitmexWsError> {
658 log::debug!("Starting close process");
659
660 self.signal.store(true, Ordering::Relaxed);
661
662 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
664 log::debug!(
665 "Failed to send disconnect command (handler may already be shut down): {e}"
666 );
667 }
668
669 if let Some(task_handle) = self.task_handle.take() {
671 match Arc::try_unwrap(task_handle) {
672 Ok(handle) => {
673 log::debug!("Waiting for task handle to complete");
674 match tokio::time::timeout(Duration::from_secs(2), handle).await {
675 Ok(Ok(())) => log::debug!("Task handle completed successfully"),
676 Ok(Err(e)) => log::error!("Task handle encountered an error: {e:?}"),
677 Err(_) => {
678 log::warn!(
679 "Timeout waiting for task handle, task may still be running"
680 );
681 }
683 }
684 }
685 Err(arc_handle) => {
686 log::debug!(
687 "Cannot take ownership of task handle - other references exist, aborting task"
688 );
689 arc_handle.abort();
690 }
691 }
692 } else {
693 log::debug!("No task handle to await");
694 }
695
696 log::debug!("Closed");
697
698 if let Some(control) = &self.socket_control {
699 control.deregister();
700 }
701
702 Ok(())
703 }
704
705 pub async fn subscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
711 log::debug!("Subscribing to topics: {topics:?}");
712
713 for topic in &topics {
714 self.subscriptions.mark_subscribe(topic.as_str());
715 self.tracked_subscriptions.insert(topic.clone(), ());
716 }
717
718 let mut payloads = Vec::with_capacity(topics.len());
720 for topic in &topics {
721 let message = BitmexSubscription {
722 op: BitmexWsOperation::Subscribe,
723 args: vec![Ustr::from(topic.as_ref())],
724 };
725 let payload = serde_json::to_string(&message).map_err(|e| {
726 BitmexWsError::SubscriptionError(format!("Failed to serialize subscription: {e}"))
727 })?;
728 payloads.push(payload);
729 }
730
731 let cmd = HandlerCommand::Subscribe { topics: payloads };
733
734 self.send_cmd(cmd).await.map_err(|e| {
735 BitmexWsError::SubscriptionError(format!("Failed to send subscribe command: {e}"))
736 })
737 }
738
739 async fn unsubscribe(&self, topics: Vec<String>) -> Result<(), BitmexWsError> {
745 log::debug!("Attempting to unsubscribe from topics: {topics:?}");
746
747 if self.signal.load(Ordering::Relaxed) {
748 log::debug!("Shutdown signal detected, skipping unsubscribe");
749 return Ok(());
750 }
751
752 for topic in &topics {
753 self.subscriptions.mark_unsubscribe(topic.as_str());
754 self.tracked_subscriptions.remove(topic);
755 }
756
757 let mut payloads = Vec::with_capacity(topics.len());
759 for topic in &topics {
760 let message = BitmexSubscription {
761 op: BitmexWsOperation::Unsubscribe,
762 args: vec![Ustr::from(topic.as_ref())],
763 };
764
765 if let Ok(payload) = serde_json::to_string(&message) {
766 payloads.push(payload);
767 }
768 }
769
770 let cmd = HandlerCommand::Unsubscribe { topics: payloads };
772
773 if let Err(e) = self.send_cmd(cmd).await {
774 log::debug!("Failed to send unsubscribe command: {e}");
775 }
776
777 Ok(())
778 }
779
780 #[must_use]
782 pub fn subscription_count(&self) -> usize {
783 self.subscriptions.len()
784 }
785
786 pub fn get_subscriptions(&self, instrument_id: InstrumentId) -> Vec<String> {
787 let symbol = instrument_id.symbol.inner();
788 let confirmed = self.subscriptions.confirmed();
789 let mut channels = Vec::with_capacity(confirmed.len());
790
791 for (channel, symbols) in confirmed.iter() {
792 if symbols.contains(&symbol) {
793 channels.push(format!("{channel}:{symbol}"));
795 } else {
796 let has_channel_marker = symbols.iter().any(|s| s.is_empty());
797 if has_channel_marker
798 && (*channel == BitmexWsAuthChannel::Execution.as_ref()
799 || *channel == BitmexWsAuthChannel::Order.as_ref())
800 {
801 channels.push(channel.to_string());
803 }
804 }
805 }
806
807 channels
808 }
809
810 pub async fn subscribe_instruments(&self) -> Result<(), BitmexWsError> {
816 log::debug!("Already subscribed to all instruments on connection, skipping");
818 Ok(())
819 }
820
821 pub async fn subscribe_instrument(
827 &self,
828 instrument_id: InstrumentId,
829 ) -> Result<(), BitmexWsError> {
830 log::debug!(
832 "Already subscribed to all instruments on connection (includes {instrument_id}), skipping"
833 );
834 Ok(())
835 }
836
837 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
843 let topic = BitmexWsTopic::OrderBookL2;
844 let symbol = instrument_id.symbol.inner();
845 self.subscribe(vec![format!("{topic}:{symbol}")]).await
846 }
847
848 pub async fn subscribe_book_25(
854 &self,
855 instrument_id: InstrumentId,
856 ) -> Result<(), BitmexWsError> {
857 let topic = BitmexWsTopic::OrderBookL2_25;
858 let symbol = instrument_id.symbol.inner();
859 self.subscribe(vec![format!("{topic}:{symbol}")]).await
860 }
861
862 pub async fn subscribe_book_depth(
868 &self,
869 instrument_id: InstrumentId,
870 ) -> Result<(), BitmexWsError> {
871 let topic = BitmexWsTopic::OrderBook10;
872 let symbol = instrument_id.symbol.inner();
873 self.subscribe(vec![format!("{topic}:{symbol}")]).await
874 }
875
876 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
884 let symbol = instrument_id.symbol.inner();
885
886 if is_index_symbol(&instrument_id.symbol.inner()) {
888 log::warn!("Ignoring quote subscription for index symbol: {symbol}");
889 return Ok(());
890 }
891
892 let topic = BitmexWsTopic::Quote;
893 self.subscribe(vec![format!("{topic}:{symbol}")]).await
894 }
895
896 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
904 let symbol = instrument_id.symbol.inner();
905
906 if is_index_symbol(&symbol) {
908 log::warn!("Ignoring trade subscription for index symbol: {symbol}");
909 return Ok(());
910 }
911
912 let topic = BitmexWsTopic::Trade;
913 self.subscribe(vec![format!("{topic}:{symbol}")]).await
914 }
915
916 pub async fn subscribe_mark_prices(
922 &self,
923 instrument_id: InstrumentId,
924 ) -> Result<(), BitmexWsError> {
925 self.subscribe_instrument(instrument_id).await
926 }
927
928 pub async fn subscribe_index_prices(
934 &self,
935 instrument_id: InstrumentId,
936 ) -> Result<(), BitmexWsError> {
937 self.subscribe_instrument(instrument_id).await
938 }
939
940 pub async fn subscribe_funding_rates(
946 &self,
947 instrument_id: InstrumentId,
948 ) -> Result<(), BitmexWsError> {
949 let topic = BitmexWsTopic::Funding;
950 let symbol = instrument_id.symbol.inner();
951 self.subscribe(vec![format!("{topic}:{symbol}")]).await
952 }
953
954 pub async fn subscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
960 let topic = topic_from_bar_spec(bar_type.spec());
961 let symbol = bar_type.instrument_id().symbol.inner();
962 self.subscribe(vec![format!("{topic}:{symbol}")]).await
963 }
964
965 pub async fn unsubscribe_instruments(&self) -> Result<(), BitmexWsError> {
971 log::debug!(
973 "Instruments subscription maintained for proper operation, skipping unsubscribe"
974 );
975 Ok(())
976 }
977
978 pub async fn unsubscribe_instrument(
984 &self,
985 instrument_id: InstrumentId,
986 ) -> Result<(), BitmexWsError> {
987 log::debug!(
989 "Instruments subscription maintained for proper operation (includes {instrument_id}), skipping unsubscribe"
990 );
991 Ok(())
992 }
993
994 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), BitmexWsError> {
1000 let topic = BitmexWsTopic::OrderBookL2;
1001 let symbol = instrument_id.symbol.inner();
1002 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1003 }
1004
1005 pub async fn unsubscribe_book_25(
1011 &self,
1012 instrument_id: InstrumentId,
1013 ) -> Result<(), BitmexWsError> {
1014 let topic = BitmexWsTopic::OrderBookL2_25;
1015 let symbol = instrument_id.symbol.inner();
1016 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1017 }
1018
1019 pub async fn unsubscribe_book_depth(
1025 &self,
1026 instrument_id: InstrumentId,
1027 ) -> Result<(), BitmexWsError> {
1028 let topic = BitmexWsTopic::OrderBook10;
1029 let symbol = instrument_id.symbol.inner();
1030 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1031 }
1032
1033 pub async fn unsubscribe_quotes(
1039 &self,
1040 instrument_id: InstrumentId,
1041 ) -> Result<(), BitmexWsError> {
1042 let symbol = instrument_id.symbol.inner();
1043
1044 if is_index_symbol(&symbol) {
1046 return Ok(());
1047 }
1048
1049 let topic = BitmexWsTopic::Quote;
1050 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1051 }
1052
1053 pub async fn unsubscribe_trades(
1059 &self,
1060 instrument_id: InstrumentId,
1061 ) -> Result<(), BitmexWsError> {
1062 let symbol = instrument_id.symbol.inner();
1063
1064 if is_index_symbol(&symbol) {
1066 return Ok(());
1067 }
1068
1069 let topic = BitmexWsTopic::Trade;
1070 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1071 }
1072
1073 pub async fn unsubscribe_mark_prices(
1079 &self,
1080 instrument_id: InstrumentId,
1081 ) -> Result<(), BitmexWsError> {
1082 log::debug!(
1084 "Mark prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1085 );
1086 Ok(())
1087 }
1088
1089 pub async fn unsubscribe_index_prices(
1095 &self,
1096 instrument_id: InstrumentId,
1097 ) -> Result<(), BitmexWsError> {
1098 log::debug!(
1100 "Index prices for {instrument_id} uses shared instrument channel, skipping unsubscribe"
1101 );
1102 Ok(())
1103 }
1104
1105 pub async fn unsubscribe_funding_rates(
1111 &self,
1112 instrument_id: InstrumentId,
1113 ) -> Result<(), BitmexWsError> {
1114 log::debug!(
1116 "Funding rates for {instrument_id}, skipping unsubscribe to avoid shutdown race"
1117 );
1118 Ok(())
1119 }
1120
1121 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> Result<(), BitmexWsError> {
1127 let topic = topic_from_bar_spec(bar_type.spec());
1128 let symbol = bar_type.instrument_id().symbol.inner();
1129 self.unsubscribe(vec![format!("{topic}:{symbol}")]).await
1130 }
1131
1132 pub async fn subscribe_orders(&self) -> Result<(), BitmexWsError> {
1138 if self.credential.is_none() {
1139 return Err(BitmexWsError::MissingCredentials);
1140 }
1141 self.subscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1142 .await
1143 }
1144
1145 pub async fn subscribe_executions(&self) -> Result<(), BitmexWsError> {
1151 if self.credential.is_none() {
1152 return Err(BitmexWsError::MissingCredentials);
1153 }
1154 self.subscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1155 .await
1156 }
1157
1158 pub async fn subscribe_positions(&self) -> Result<(), BitmexWsError> {
1164 if self.credential.is_none() {
1165 return Err(BitmexWsError::MissingCredentials);
1166 }
1167 self.subscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1168 .await
1169 }
1170
1171 pub async fn subscribe_margin(&self) -> Result<(), BitmexWsError> {
1177 if self.credential.is_none() {
1178 return Err(BitmexWsError::MissingCredentials);
1179 }
1180 self.subscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1181 .await
1182 }
1183
1184 pub async fn subscribe_wallet(&self) -> Result<(), BitmexWsError> {
1190 if self.credential.is_none() {
1191 return Err(BitmexWsError::MissingCredentials);
1192 }
1193 self.subscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1194 .await
1195 }
1196
1197 pub async fn unsubscribe_orders(&self) -> Result<(), BitmexWsError> {
1203 self.unsubscribe(vec![BitmexWsAuthChannel::Order.to_string()])
1204 .await
1205 }
1206
1207 pub async fn unsubscribe_executions(&self) -> Result<(), BitmexWsError> {
1213 self.unsubscribe(vec![BitmexWsAuthChannel::Execution.to_string()])
1214 .await
1215 }
1216
1217 pub async fn unsubscribe_positions(&self) -> Result<(), BitmexWsError> {
1223 self.unsubscribe(vec![BitmexWsAuthChannel::Position.to_string()])
1224 .await
1225 }
1226
1227 pub async fn unsubscribe_margin(&self) -> Result<(), BitmexWsError> {
1233 self.unsubscribe(vec![BitmexWsAuthChannel::Margin.to_string()])
1234 .await
1235 }
1236
1237 pub async fn unsubscribe_wallet(&self) -> Result<(), BitmexWsError> {
1243 self.unsubscribe(vec![BitmexWsAuthChannel::Wallet.to_string()])
1244 .await
1245 }
1246
1247 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), BitmexWsError> {
1249 self.cmd_tx
1250 .read()
1251 .await
1252 .send(cmd)
1253 .map_err(|e| BitmexWsError::ClientError(format!("Handler not available: {e}")))
1254 }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use rstest::rstest;
1260
1261 use super::*;
1262
1263 #[rstest]
1264 fn test_debug_redacts_credentials_and_proxy() {
1265 let client = BitmexWebSocketClient::new(
1266 Some("ws://test.com".to_string()),
1267 Some("websocket-key-sentinel".to_string()),
1268 Some("websocket-secret-sentinel".to_string()),
1269 Some(AccountId::new("BITMEX-TEST")),
1270 5,
1271 None,
1272 TransportBackend::default(),
1273 Some("http://websocket-user:websocket-password@localhost".to_string()),
1274 )
1275 .unwrap();
1276
1277 let debug = format!("{client:?}");
1278
1279 assert!(!debug.contains("websocket-key-sentinel"));
1280 assert!(!debug.contains("websocket-secret-sentinel"));
1281 assert!(!debug.contains("websocket-password"));
1282 }
1283
1284 #[rstest]
1285 fn test_reconnect_topics_restoration_logic() {
1286 let client = BitmexWebSocketClient::new(
1288 Some("ws://test.com".to_string()),
1289 Some("test_key".to_string()),
1290 Some("test_secret".to_string()),
1291 Some(AccountId::new("BITMEX-TEST")),
1292 5,
1293 None,
1294 TransportBackend::default(),
1295 None,
1296 )
1297 .unwrap();
1298
1299 for topic in [
1301 format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref()),
1302 format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref()),
1303 format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()),
1304 BitmexWsAuthChannel::Order.as_ref().to_string(),
1305 BitmexWsAuthChannel::Position.as_ref().to_string(),
1306 ] {
1307 client.subscriptions.mark_subscribe(&topic);
1308 client.subscriptions.confirm_subscribe(&topic);
1309 }
1310
1311 let topics_to_restore = client.subscriptions.all_topics();
1313
1314 assert!(topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref())));
1316 assert!(topics_to_restore.contains(&format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref())));
1317 assert!(
1318 topics_to_restore.contains(&format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()))
1319 );
1320 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Order.as_ref().to_string()));
1321 assert!(topics_to_restore.contains(&BitmexWsAuthChannel::Position.as_ref().to_string()));
1322 assert_eq!(topics_to_restore.len(), 5);
1323 }
1324
1325 #[rstest]
1326 fn test_reconnect_auth_message_building() {
1327 let client_with_creds = BitmexWebSocketClient::new(
1329 Some("ws://test.com".to_string()),
1330 Some("test_key".to_string()),
1331 Some("test_secret".to_string()),
1332 Some(AccountId::new("BITMEX-TEST")),
1333 5,
1334 None,
1335 TransportBackend::default(),
1336 None,
1337 )
1338 .unwrap();
1339
1340 if let Some(cred) = &client_with_creds.credential {
1342 let expires =
1343 (jiff::Timestamp::now() + jiff::SignedDuration::from_secs(30)).as_second();
1344 let signature = cred.sign("GET", "/realtime", expires, "");
1345
1346 let auth_message = BitmexAuthentication {
1347 op: BitmexWsAuthAction::AuthKeyExpires,
1348 args: (cred.api_key().to_string(), expires, signature),
1349 };
1350
1351 assert_eq!(auth_message.op, BitmexWsAuthAction::AuthKeyExpires);
1353 assert_eq!(auth_message.args.0, "test_key");
1354 assert!(auth_message.args.1 > 0); assert!(!auth_message.args.2.is_empty()); } else {
1357 panic!("Client should have credentials");
1358 }
1359
1360 let client_no_creds = BitmexWebSocketClient::new(
1362 Some("ws://test.com".to_string()),
1363 None,
1364 None,
1365 Some(AccountId::new("BITMEX-TEST")),
1366 5,
1367 None,
1368 TransportBackend::default(),
1369 None,
1370 )
1371 .unwrap();
1372
1373 assert!(client_no_creds.credential.is_none());
1374 }
1375
1376 #[rstest]
1377 fn test_subscription_state_after_unsubscribe() {
1378 let client = BitmexWebSocketClient::new(
1379 Some("ws://test.com".to_string()),
1380 Some("test_key".to_string()),
1381 Some("test_secret".to_string()),
1382 Some(AccountId::new("BITMEX-TEST")),
1383 5,
1384 None,
1385 TransportBackend::default(),
1386 None,
1387 )
1388 .unwrap();
1389
1390 for topic in [
1392 format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref()),
1393 format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref()),
1394 format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref()),
1395 ] {
1396 client.subscriptions.mark_subscribe(&topic);
1397 client.subscriptions.confirm_subscribe(&topic);
1398 }
1399
1400 let topic = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1402 client.subscriptions.mark_unsubscribe(&topic);
1403 client.subscriptions.confirm_unsubscribe(&topic);
1404
1405 let topics_to_restore = client.subscriptions.all_topics();
1407
1408 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1410 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1411 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1412
1413 assert!(topics_to_restore.contains(&trade_xbt));
1414 assert!(!topics_to_restore.contains(&trade_eth));
1415 assert!(topics_to_restore.contains(&book_xbt));
1416 assert_eq!(topics_to_restore.len(), 2);
1417 }
1418
1419 #[rstest]
1420 fn test_race_unsubscribe_failure_recovery() {
1421 let client = BitmexWebSocketClient::new(
1427 Some("ws://test.com".to_string()),
1428 None,
1429 None,
1430 Some(AccountId::new("BITMEX-TEST")),
1431 5,
1432 None,
1433 TransportBackend::default(),
1434 None,
1435 )
1436 .unwrap();
1437
1438 let topic = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1439
1440 client.subscriptions.mark_subscribe(&topic);
1442 client.subscriptions.confirm_subscribe(&topic);
1443 assert_eq!(client.subscriptions.len(), 1);
1444
1445 client.subscriptions.mark_unsubscribe(&topic);
1447 assert_eq!(client.subscriptions.len(), 0);
1448 assert_eq!(
1449 client.subscriptions.pending_unsubscribe_topics(),
1450 vec![topic.clone()]
1451 );
1452
1453 client.subscriptions.confirm_unsubscribe(&topic); client.subscriptions.mark_subscribe(&topic); client.subscriptions.confirm_subscribe(&topic); assert_eq!(client.subscriptions.len(), 1);
1461 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1462 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1463
1464 let all = client.subscriptions.all_topics();
1466 assert_eq!(all.len(), 1);
1467 assert!(all.contains(&topic));
1468 }
1469
1470 #[rstest]
1471 fn test_race_resubscribe_before_unsubscribe_ack() {
1472 let client = BitmexWebSocketClient::new(
1476 Some("ws://test.com".to_string()),
1477 None,
1478 None,
1479 Some(AccountId::new("BITMEX-TEST")),
1480 5,
1481 None,
1482 TransportBackend::default(),
1483 None,
1484 )
1485 .unwrap();
1486
1487 let topic = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1488
1489 client.subscriptions.mark_subscribe(&topic);
1491 client.subscriptions.confirm_subscribe(&topic);
1492 assert_eq!(client.subscriptions.len(), 1);
1493
1494 client.subscriptions.mark_unsubscribe(&topic);
1496 assert_eq!(client.subscriptions.len(), 0);
1497 assert_eq!(
1498 client.subscriptions.pending_unsubscribe_topics(),
1499 vec![topic.clone()]
1500 );
1501
1502 client.subscriptions.mark_subscribe(&topic);
1504 assert_eq!(
1505 client.subscriptions.pending_subscribe_topics(),
1506 vec![topic.clone()]
1507 );
1508
1509 client.subscriptions.confirm_unsubscribe(&topic);
1511 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1512 assert_eq!(
1513 client.subscriptions.pending_subscribe_topics(),
1514 vec![topic.clone()]
1515 );
1516
1517 client.subscriptions.confirm_subscribe(&topic);
1519 assert_eq!(client.subscriptions.len(), 1);
1520 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1521
1522 let all = client.subscriptions.all_topics();
1524 assert_eq!(all.len(), 1);
1525 assert!(all.contains(&topic));
1526 }
1527
1528 #[rstest]
1529 fn test_race_channel_level_reconnection_with_pending_states() {
1530 let client = BitmexWebSocketClient::new(
1532 Some("ws://test.com".to_string()),
1533 Some("test_key".to_string()),
1534 Some("test_secret".to_string()),
1535 Some(AccountId::new("BITMEX-TEST")),
1536 5,
1537 None,
1538 TransportBackend::default(),
1539 None,
1540 )
1541 .unwrap();
1542
1543 let trade_xbt = format!("{}:XBTUSD", BitmexWsTopic::Trade.as_ref());
1546 client.subscriptions.mark_subscribe(&trade_xbt);
1547 client.subscriptions.confirm_subscribe(&trade_xbt);
1548
1549 let order_channel = BitmexWsAuthChannel::Order.as_ref();
1551 client.subscriptions.mark_subscribe(order_channel);
1552 client.subscriptions.confirm_subscribe(order_channel);
1553
1554 let trade_eth = format!("{}:ETHUSD", BitmexWsTopic::Trade.as_ref());
1556 client.subscriptions.mark_subscribe(&trade_eth);
1557
1558 let book_xbt = format!("{}:XBTUSD", BitmexWsTopic::OrderBookL2.as_ref());
1560 client.subscriptions.mark_subscribe(&book_xbt);
1561 client.subscriptions.confirm_subscribe(&book_xbt);
1562 client.subscriptions.mark_unsubscribe(&book_xbt);
1563
1564 let topics_to_restore = client.subscriptions.all_topics();
1566
1567 assert_eq!(topics_to_restore.len(), 3);
1569 assert!(topics_to_restore.contains(&trade_xbt));
1570 assert!(topics_to_restore.contains(&order_channel.to_string()));
1571 assert!(topics_to_restore.contains(&trade_eth));
1572 assert!(!topics_to_restore.contains(&book_xbt)); for topic in &topics_to_restore {
1577 if topic == order_channel {
1578 assert!(
1579 !topic.contains(':'),
1580 "Channel-level topic should not have delimiter"
1581 );
1582 }
1583 }
1584 }
1585}