1pub static DYDX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
48 LazyLock::new(|| [Ustr::from("subscription")]);
49
50pub const DYDX_WS_TOPIC_DELIMITER: char = ':';
52
53pub static DYDX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
55 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
56});
57
58pub const DEFAULT_MAX_WS_CONNECTIONS: usize = 8;
60
61pub const DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT: usize = 32;
63
64use std::{
65 num::NonZeroU32,
66 sync::{
67 Arc, LazyLock, Mutex,
68 atomic::{AtomicBool, AtomicU8, Ordering},
69 },
70 time::Duration,
71};
72
73use ahash::{AHashMap, AHashSet};
74use arc_swap::ArcSwap;
75use dashmap::DashMap;
76use nautilus_common::live::get_runtime;
77use nautilus_model::{
78 data::BarType,
79 identifiers::{AccountId, InstrumentId},
80 instruments::InstrumentAny,
81};
82use nautilus_network::{
83 mode::ConnectionMode,
84 ratelimiter::quota::Quota,
85 websocket::{
86 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
87 channel_message_handler,
88 },
89};
90use ustr::Ustr;
91
92use super::{
93 dispatch::DydxWsDispatchState,
94 enums::{DydxWsChannel, DydxWsOperation, DydxWsOutputMessage},
95 error::{DydxWsError, DydxWsResult},
96 handler::{FeedHandler, HandlerCommand},
97 messages::DydxSubscription,
98};
99use crate::{
100 common::{credential::DydxCredential, instrument_cache::InstrumentCache},
101 execution::encoder::ClientOrderIdEncoder,
102};
103
104#[derive(Copy, Clone, Debug)]
106#[repr(u8)]
107enum ChannelKind {
108 Trades = 0,
109 Candles = 1,
110 Orderbook = 2,
111 Markets = 3,
112}
113
114const CHANNEL_KIND_COUNT: usize = 4;
115
116#[derive(Debug)]
118struct ConnectionSlot {
119 cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
120 topics: AHashMap<String, u32>,
121 channel_counts: [u16; CHANNEL_KIND_COUNT],
122 subscriptions_state: SubscriptionState,
123 handler_task: Option<tokio::task::JoinHandle<()>>,
124 connection_mode: Arc<AtomicU8>,
125}
126
127#[derive(Debug)]
148#[cfg_attr(
149 feature = "python",
150 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.dydx", from_py_object)
151)]
152#[cfg_attr(
153 feature = "python",
154 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
155)]
156pub struct DydxWebSocketClient {
157 url: String,
158 credential: Option<Arc<DydxCredential>>,
159 requires_auth: bool,
160 auth_tracker: AuthTracker,
161 slots: Arc<Mutex<Vec<ConnectionSlot>>>,
162 connect_lock: Arc<tokio::sync::Mutex<()>>,
163 connection_mode: Arc<ArcSwap<AtomicU8>>,
164 signal: Arc<AtomicBool>,
165 instrument_cache: Arc<InstrumentCache>,
166 account_id: Option<AccountId>,
167 heartbeat: Option<u64>,
168 out_tx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<DydxWsOutputMessage>>>>,
169 out_rx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<DydxWsOutputMessage>>>>,
170 encoder: Arc<ClientOrderIdEncoder>,
171 bar_types: Arc<DashMap<String, BarType>>,
172 bars_timestamp_on_close: Arc<AtomicBool>,
173 ws_dispatch_state: Arc<DydxWsDispatchState>,
174 transport_backend: TransportBackend,
175 proxy_url: Option<String>,
176 max_ws_connections: usize,
177 per_channel_limit: usize,
178}
179
180impl Clone for DydxWebSocketClient {
181 fn clone(&self) -> Self {
182 Self {
183 url: self.url.clone(),
184 credential: self.credential.clone(),
185 requires_auth: self.requires_auth,
186 auth_tracker: self.auth_tracker.clone(),
187 slots: self.slots.clone(),
188 connect_lock: self.connect_lock.clone(),
189 connection_mode: self.connection_mode.clone(),
190 signal: self.signal.clone(),
191 instrument_cache: self.instrument_cache.clone(),
192 account_id: self.account_id,
193 heartbeat: self.heartbeat,
194 out_tx: self.out_tx.clone(),
195 out_rx: self.out_rx.clone(),
196 encoder: self.encoder.clone(),
197 bar_types: self.bar_types.clone(),
198 bars_timestamp_on_close: self.bars_timestamp_on_close.clone(),
199 ws_dispatch_state: self.ws_dispatch_state.clone(),
200 transport_backend: self.transport_backend,
201 proxy_url: self.proxy_url.clone(),
202 max_ws_connections: self.max_ws_connections,
203 per_channel_limit: self.per_channel_limit,
204 }
205 }
206}
207
208impl DydxWebSocketClient {
209 #[must_use]
214 pub fn new_public(url: String, heartbeat: Option<u64>, proxy_url: Option<String>) -> Self {
215 Self::new_public_with_cache(
216 url,
217 Arc::new(InstrumentCache::new()),
218 heartbeat,
219 TransportBackend::default(),
220 proxy_url,
221 )
222 }
223
224 #[must_use]
228 pub fn new_public_with_cache(
229 url: String,
230 instrument_cache: Arc<InstrumentCache>,
231 heartbeat: Option<u64>,
232 transport_backend: TransportBackend,
233 proxy_url: Option<String>,
234 ) -> Self {
235 Self::new_public_with_cache_and_pool(
236 url,
237 instrument_cache,
238 heartbeat,
239 transport_backend,
240 proxy_url,
241 DEFAULT_MAX_WS_CONNECTIONS,
242 DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT,
243 )
244 }
245
246 #[must_use]
248 pub fn new_public_with_cache_and_pool(
249 url: String,
250 instrument_cache: Arc<InstrumentCache>,
251 heartbeat: Option<u64>,
252 transport_backend: TransportBackend,
253 proxy_url: Option<String>,
254 max_ws_connections: usize,
255 per_channel_limit: usize,
256 ) -> Self {
257 Self::new_inner(
258 url,
259 None,
260 false,
261 instrument_cache,
262 None,
263 heartbeat,
264 transport_backend,
265 proxy_url,
266 max_ws_connections,
267 per_channel_limit,
268 )
269 }
270
271 #[must_use]
276 pub fn new_private(
277 url: String,
278 credential: DydxCredential,
279 account_id: AccountId,
280 heartbeat: Option<u64>,
281 proxy_url: Option<String>,
282 ) -> Self {
283 Self::new_private_with_cache(
284 url,
285 credential,
286 account_id,
287 Arc::new(InstrumentCache::new()),
288 heartbeat,
289 TransportBackend::default(),
290 proxy_url,
291 )
292 }
293
294 #[must_use]
298 pub fn new_private_with_cache(
299 url: String,
300 credential: DydxCredential,
301 account_id: AccountId,
302 instrument_cache: Arc<InstrumentCache>,
303 heartbeat: Option<u64>,
304 transport_backend: TransportBackend,
305 proxy_url: Option<String>,
306 ) -> Self {
307 Self::new_inner(
308 url,
309 Some(Arc::new(credential)),
310 true,
311 instrument_cache,
312 Some(account_id),
313 heartbeat,
314 transport_backend,
315 proxy_url,
316 DEFAULT_MAX_WS_CONNECTIONS,
317 DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT,
318 )
319 }
320
321 #[allow(clippy::too_many_arguments)]
322 fn new_inner(
323 url: String,
324 credential: Option<Arc<DydxCredential>>,
325 requires_auth: bool,
326 instrument_cache: Arc<InstrumentCache>,
327 account_id: Option<AccountId>,
328 heartbeat: Option<u64>,
329 transport_backend: TransportBackend,
330 proxy_url: Option<String>,
331 max_ws_connections: usize,
332 per_channel_limit: usize,
333 ) -> Self {
334 Self {
335 url,
336 credential,
337 requires_auth,
338 auth_tracker: AuthTracker::new(),
339 slots: Arc::new(Mutex::new(Vec::new())),
340 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
341 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
342 ConnectionMode::Closed as u8,
343 ))),
344 signal: Arc::new(AtomicBool::new(false)),
345 instrument_cache,
346 account_id,
347 heartbeat,
348 out_tx: Arc::new(Mutex::new(None)),
349 out_rx: Arc::new(Mutex::new(None)),
350 encoder: Arc::new(ClientOrderIdEncoder::new()),
351 bar_types: Arc::new(DashMap::new()),
352 bars_timestamp_on_close: Arc::new(AtomicBool::new(true)),
353 ws_dispatch_state: Arc::new(DydxWsDispatchState::default()),
354 transport_backend,
355 proxy_url,
356 max_ws_connections: max_ws_connections.max(1),
357 per_channel_limit: per_channel_limit.max(1),
358 }
359 }
360
361 #[must_use]
363 pub fn credential(&self) -> Option<&Arc<DydxCredential>> {
364 self.credential.as_ref()
365 }
366
367 #[must_use]
369 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
370 pub fn is_connected(&self) -> bool {
371 let slots = self.slots.lock().expect("slots lock poisoned");
372 slots.iter().any(|s| {
373 let mode = s.connection_mode.load(Ordering::Relaxed);
374 mode == ConnectionMode::Active as u8 || mode == ConnectionMode::Reconnect as u8
375 })
376 }
377
378 #[must_use]
380 pub fn url(&self) -> &str {
381 &self.url
382 }
383
384 #[must_use]
389 pub fn connection_mode_atomic(&self) -> Arc<ArcSwap<AtomicU8>> {
390 self.connection_mode.clone()
391 }
392
393 #[must_use]
395 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
396 pub fn pool_size(&self) -> usize {
397 self.slots.lock().expect("slots lock poisoned").len()
398 }
399
400 #[must_use]
402 pub const fn max_ws_connections(&self) -> usize {
403 self.max_ws_connections
404 }
405
406 #[must_use]
408 pub const fn per_channel_limit(&self) -> usize {
409 self.per_channel_limit
410 }
411
412 pub fn set_account_id(&mut self, account_id: AccountId) {
414 self.account_id = Some(account_id);
415 }
416
417 #[must_use]
419 pub fn account_id(&self) -> Option<AccountId> {
420 self.account_id
421 }
422
423 pub fn set_instrument_cache(&mut self, cache: Arc<InstrumentCache>) {
429 self.instrument_cache = cache;
430 }
431
432 pub fn cache_instrument(&self, instrument: InstrumentAny) {
436 self.instrument_cache.insert_instrument_only(instrument);
437 }
438
439 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
443 log::debug!(
444 "Caching {} instruments in WebSocket client",
445 instruments.len()
446 );
447 self.instrument_cache.insert_instruments_only(instruments);
448 }
449
450 #[must_use]
452 pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
453 &self.instrument_cache
454 }
455
456 #[must_use]
458 pub fn encoder(&self) -> &Arc<ClientOrderIdEncoder> {
459 &self.encoder
460 }
461
462 #[must_use]
464 pub fn bar_types(&self) -> &Arc<DashMap<String, BarType>> {
465 &self.bar_types
466 }
467
468 pub fn ws_dispatch_state(&self) -> &Arc<DydxWsDispatchState> {
470 &self.ws_dispatch_state
471 }
472
473 pub fn set_bars_timestamp_on_close(&self, value: bool) {
475 self.bars_timestamp_on_close.store(value, Ordering::Relaxed);
476 }
477
478 #[must_use]
480 pub fn bars_timestamp_on_close(&self) -> bool {
481 self.bars_timestamp_on_close.load(Ordering::Relaxed)
482 }
483
484 #[must_use]
488 pub fn all_instruments(&self) -> Vec<InstrumentAny> {
489 self.instrument_cache.all_instruments()
490 }
491
492 #[must_use]
494 pub fn cached_instruments_count(&self) -> usize {
495 self.instrument_cache.len()
496 }
497
498 #[must_use]
502 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
503 self.instrument_cache.get(instrument_id)
504 }
505
506 #[must_use]
510 pub fn get_instrument_by_market(&self, ticker: &str) -> Option<InstrumentAny> {
511 self.instrument_cache.get_by_market(ticker)
512 }
513
514 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
517 pub fn take_receiver(
518 &mut self,
519 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<DydxWsOutputMessage>> {
520 self.out_rx.lock().expect("out_rx lock poisoned").take()
521 }
522
523 pub fn stream(
531 &mut self,
532 ) -> impl futures_util::Stream<Item = DydxWsOutputMessage> + Send + 'static {
533 let mut rx = self
534 .out_rx
535 .lock()
536 .expect("out_rx lock poisoned")
537 .take()
538 .expect("Message stream receiver already taken or not connected");
539
540 async_stream::stream! {
541 while let Some(msg) = rx.recv().await {
542 yield msg;
543 }
544 }
545 }
546
547 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
556 pub async fn connect(&mut self) -> DydxWsResult<()> {
557 if self.is_connected() {
558 return Ok(());
559 }
560
561 self.signal.store(false, Ordering::Release);
562
563 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<DydxWsOutputMessage>();
564 {
565 let mut guard = self.out_tx.lock().expect("out_tx lock poisoned");
566 *guard = Some(out_tx);
567 }
568 {
569 let mut guard = self.out_rx.lock().expect("out_rx lock poisoned");
570 *guard = Some(out_rx);
571 }
572
573 let slot = self.create_connection().await?;
574 self.connection_mode.store(slot.connection_mode.clone());
575 self.slots.lock().expect("slots lock poisoned").push(slot);
576
577 log::debug!("Connected dYdX WebSocket pool: {}", self.url);
578 Ok(())
579 }
580
581 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
587 pub async fn disconnect(&mut self) -> DydxWsResult<()> {
588 self.signal.store(true, Ordering::Release);
589
590 let slots: Vec<ConnectionSlot> = {
591 let mut guard = self.slots.lock().expect("slots lock poisoned");
592 guard.drain(..).collect()
593 };
594
595 for mut slot in slots {
596 let _ = slot.cmd_tx.send(HandlerCommand::Disconnect);
597 if let Some(task) = slot.handler_task.take() {
598 let abort_handle = task.abort_handle();
599 match tokio::time::timeout(Duration::from_secs(2), task).await {
600 Ok(Ok(())) => log::debug!("Handler task completed"),
601 Ok(Err(e)) => log::error!("Handler task error: {e:?}"),
602 Err(_) => {
603 log::warn!("Timeout waiting for handler task, aborting");
604 abort_handle.abort();
605 }
606 }
607 }
608 }
609
610 self.connection_mode
611 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
612
613 *self.out_tx.lock().expect("out_tx lock poisoned") = None;
614 *self.out_rx.lock().expect("out_rx lock poisoned") = None;
615
616 log::debug!("Disconnected dYdX WebSocket pool");
617 Ok(())
618 }
619
620 #[expect(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
626 pub fn send_command(&self, cmd: HandlerCommand) -> DydxWsResult<()> {
627 let slots = self.slots.lock().expect("slots lock poisoned");
628 let slot = slots
629 .first()
630 .ok_or_else(|| DydxWsError::Transport("No pool slots available".to_string()))?;
631 slot.cmd_tx.send(cmd).map_err(|e| {
632 DydxWsError::Transport(format!("Failed to send command to slot 0: {e}"))
633 })?;
634 Ok(())
635 }
636
637 async fn create_connection(&self) -> DydxWsResult<ConnectionSlot> {
638 let (message_handler, raw_rx) = channel_message_handler();
639
640 let cfg = WebSocketConfig {
641 url: self.url.clone(),
642 headers: vec![],
643 heartbeat: self.heartbeat,
644 heartbeat_msg: None,
645 reconnect_timeout_ms: Some(15_000),
646 reconnect_delay_initial_ms: Some(250),
647 reconnect_delay_max_ms: Some(5_000),
648 reconnect_backoff_factor: Some(2.0),
649 reconnect_jitter_ms: Some(200),
650 reconnect_max_attempts: None,
651 idle_timeout_ms: None,
652 backend: self.transport_backend,
653 proxy_url: self.proxy_url.clone(),
654 };
655
656 let client = WebSocketClient::connect(
657 cfg,
658 Some(message_handler),
659 None,
660 None,
661 vec![],
662 Some(*DYDX_WS_SUBSCRIPTION_QUOTA),
663 )
664 .await
665 .map_err(|e| DydxWsError::Transport(e.to_string()))?;
666
667 let connection_mode = client.connection_mode_atomic();
668 let subscriptions_state = SubscriptionState::new(DYDX_WS_TOPIC_DELIMITER);
669
670 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
671
672 let out_tx = self
673 .out_tx
674 .lock()
675 .expect("out_tx lock poisoned")
676 .clone()
677 .ok_or_else(|| DydxWsError::Transport("Output channel not initialized".to_string()))?;
678
679 let signal = self.signal.clone();
680 let subscriptions = subscriptions_state.clone();
681
682 let handler_task = get_runtime().spawn(async move {
683 let mut handler =
684 FeedHandler::new(cmd_rx, out_tx, raw_rx, client, signal, subscriptions);
685 handler.run().await;
686 });
687
688 Ok(ConnectionSlot {
689 cmd_tx,
690 topics: AHashMap::new(),
691 channel_counts: [0; CHANNEL_KIND_COUNT],
692 subscriptions_state,
693 handler_task: Some(handler_task),
694 connection_mode,
695 })
696 }
697
698 fn ticker_from_instrument_id(instrument_id: &InstrumentId) -> String {
699 let mut s = instrument_id.symbol.as_str().to_string();
700 if let Some(stripped) = s.strip_suffix("-PERP") {
701 s = stripped.to_string();
702 }
703 s
704 }
705
706 fn topic(channel: DydxWsChannel, id: Option<&str>) -> String {
707 match id {
708 Some(id) => format!("{}{}{}", channel.as_ref(), DYDX_WS_TOPIC_DELIMITER, id),
709 None => channel.as_ref().to_string(),
710 }
711 }
712
713 async fn subscribe_topic(
714 &self,
715 channel: ChannelKind,
716 topic: String,
717 sub_msg: DydxSubscription,
718 ) -> DydxWsResult<()> {
719 let _connect_guard = self.connect_lock.lock().await;
720
721 {
722 let mut slots = self.slots.lock().expect("slots lock poisoned");
723 if let Some(slot) = slots.iter_mut().find(|s| s.topics.contains_key(&topic)) {
724 *slot.topics.get_mut(&topic).expect("topic refcount present") += 1;
725 return Ok(());
726 }
727 }
728
729 let target_idx = loop {
730 {
731 let slots = self.slots.lock().expect("slots lock poisoned");
732 if let Some(idx) = slots.iter().position(|s| {
733 (s.channel_counts[channel as usize] as usize) < self.per_channel_limit
734 }) {
735 break idx;
736 }
737
738 if slots.len() >= self.max_ws_connections {
739 return Err(DydxWsError::Subscription(format!(
740 "Pool exhausted: {} connections x {} {:?} subscriptions",
741 self.max_ws_connections, self.per_channel_limit, channel,
742 )));
743 }
744 }
745
746 let new_slot = self.create_connection().await?;
747 let new_idx = {
748 let mut slots = self.slots.lock().expect("slots lock poisoned");
749 slots.push(new_slot);
750 slots.len() - 1
751 };
752 log::debug!(
753 "dYdX pool slot {new_idx} connected: url={}, channel={:?}",
754 self.url,
755 channel,
756 );
757 };
758
759 let mut slots = self.slots.lock().expect("slots lock poisoned");
760 let slot = &mut slots[target_idx];
761
762 slot.subscriptions_state.mark_subscribe(&topic);
763 slot.cmd_tx
764 .send(HandlerCommand::RegisterSubscription {
765 topic: topic.clone(),
766 subscription: sub_msg.clone(),
767 })
768 .map_err(|e| {
769 slot.subscriptions_state.mark_failure(&topic);
770 DydxWsError::Transport(format!("Slot {target_idx} unavailable: {e}"))
771 })?;
772
773 let payload = serde_json::to_string(&sub_msg)?;
774 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
775 slot.subscriptions_state.mark_failure(&topic);
776 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
777 topic: topic.clone(),
778 });
779 return Err(DydxWsError::Transport(format!(
780 "Slot {target_idx} send failed: {e}"
781 )));
782 }
783
784 slot.topics.insert(topic, 1);
785 slot.channel_counts[channel as usize] =
786 slot.channel_counts[channel as usize].saturating_add(1);
787
788 Ok(())
789 }
790
791 async fn unsubscribe_topic(
792 &self,
793 channel: ChannelKind,
794 topic: String,
795 unsub_msg: DydxSubscription,
796 ) -> DydxWsResult<()> {
797 let mut slots = self.slots.lock().expect("slots lock poisoned");
798 let Some(slot_idx) = slots.iter().position(|s| s.topics.contains_key(&topic)) else {
799 return Ok(());
800 };
801
802 let slot = &mut slots[slot_idx];
803 let refcount = slot.topics.get_mut(&topic).expect("topic present");
804 if *refcount > 1 {
805 *refcount -= 1;
806 return Ok(());
807 }
808
809 slot.subscriptions_state.mark_unsubscribe(&topic);
810 let payload = serde_json::to_string(&unsub_msg)?;
811 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
812 slot.subscriptions_state.mark_subscribe(&topic);
813 return Err(DydxWsError::Transport(format!(
814 "Slot {slot_idx} send failed: {e}"
815 )));
816 }
817 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
818 topic: topic.clone(),
819 });
820
821 slot.topics.remove(&topic);
822 slot.channel_counts[channel as usize] =
823 slot.channel_counts[channel as usize].saturating_sub(1);
824
825 Ok(())
826 }
827
828 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
838 let ticker = Self::ticker_from_instrument_id(&instrument_id);
839 let topic = Self::topic(DydxWsChannel::Trades, Some(&ticker));
840 let sub = DydxSubscription {
841 op: DydxWsOperation::Subscribe,
842 channel: DydxWsChannel::Trades,
843 id: Some(ticker),
844 };
845 self.subscribe_topic(ChannelKind::Trades, topic, sub).await
846 }
847
848 pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
854 let ticker = Self::ticker_from_instrument_id(&instrument_id);
855 let topic = Self::topic(DydxWsChannel::Trades, Some(&ticker));
856 let sub = DydxSubscription {
857 op: DydxWsOperation::Unsubscribe,
858 channel: DydxWsChannel::Trades,
859 id: Some(ticker),
860 };
861 self.unsubscribe_topic(ChannelKind::Trades, topic, sub)
862 .await
863 }
864
865 pub async fn subscribe_orderbook(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
875 let ticker = Self::ticker_from_instrument_id(&instrument_id);
876 let topic = Self::topic(DydxWsChannel::Orderbook, Some(&ticker));
877 let sub = DydxSubscription {
878 op: DydxWsOperation::Subscribe,
879 channel: DydxWsChannel::Orderbook,
880 id: Some(ticker),
881 };
882 self.subscribe_topic(ChannelKind::Orderbook, topic, sub)
883 .await
884 }
885
886 pub async fn unsubscribe_orderbook(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
892 let ticker = Self::ticker_from_instrument_id(&instrument_id);
893 let topic = Self::topic(DydxWsChannel::Orderbook, Some(&ticker));
894 let sub = DydxSubscription {
895 op: DydxWsOperation::Unsubscribe,
896 channel: DydxWsChannel::Orderbook,
897 id: Some(ticker),
898 };
899 self.unsubscribe_topic(ChannelKind::Orderbook, topic, sub)
900 .await
901 }
902
903 pub async fn subscribe_candles(
913 &self,
914 instrument_id: InstrumentId,
915 resolution: &str,
916 ) -> DydxWsResult<()> {
917 let ticker = Self::ticker_from_instrument_id(&instrument_id);
918 let id = format!("{ticker}/{resolution}");
919 let topic = Self::topic(DydxWsChannel::Candles, Some(&id));
920 let sub = DydxSubscription {
921 op: DydxWsOperation::Subscribe,
922 channel: DydxWsChannel::Candles,
923 id: Some(id),
924 };
925 self.subscribe_topic(ChannelKind::Candles, topic, sub).await
926 }
927
928 pub async fn unsubscribe_candles(
934 &self,
935 instrument_id: InstrumentId,
936 resolution: &str,
937 ) -> DydxWsResult<()> {
938 let ticker = Self::ticker_from_instrument_id(&instrument_id);
939 let id = format!("{ticker}/{resolution}");
940 let topic = Self::topic(DydxWsChannel::Candles, Some(&id));
941 let sub = DydxSubscription {
942 op: DydxWsOperation::Unsubscribe,
943 channel: DydxWsChannel::Candles,
944 id: Some(id),
945 };
946 self.unsubscribe_topic(ChannelKind::Candles, topic, sub)
947 .await
948 }
949
950 pub async fn subscribe_markets(&self) -> DydxWsResult<()> {
960 let topic = Self::topic(DydxWsChannel::Markets, None);
961 let sub = DydxSubscription {
962 op: DydxWsOperation::Subscribe,
963 channel: DydxWsChannel::Markets,
964 id: None,
965 };
966 self.subscribe_topic(ChannelKind::Markets, topic, sub).await
967 }
968
969 pub async fn unsubscribe_markets(&self) -> DydxWsResult<()> {
975 let topic = Self::topic(DydxWsChannel::Markets, None);
976 let sub = DydxSubscription {
977 op: DydxWsOperation::Unsubscribe,
978 channel: DydxWsChannel::Markets,
979 id: None,
980 };
981 self.unsubscribe_topic(ChannelKind::Markets, topic, sub)
982 .await
983 }
984
985 pub async fn subscribe_subaccount(
1002 &self,
1003 address: &str,
1004 subaccount_number: u32,
1005 ) -> DydxWsResult<()> {
1006 if !self.requires_auth {
1007 return Err(DydxWsError::Authentication(
1008 "Subaccount subscriptions require authentication. Use new_private() to create an authenticated client".to_string(),
1009 ));
1010 }
1011 let id = format!("{address}/{subaccount_number}");
1012 let topic = Self::topic(DydxWsChannel::Subaccounts, Some(&id));
1013 let sub = DydxSubscription {
1014 op: DydxWsOperation::Subscribe,
1015 channel: DydxWsChannel::Subaccounts,
1016 id: Some(id),
1017 };
1018 self.subscribe_pinned(topic, sub).await
1019 }
1020
1021 pub async fn unsubscribe_subaccount(
1027 &self,
1028 address: &str,
1029 subaccount_number: u32,
1030 ) -> DydxWsResult<()> {
1031 let id = format!("{address}/{subaccount_number}");
1032 let topic = Self::topic(DydxWsChannel::Subaccounts, Some(&id));
1033 let sub = DydxSubscription {
1034 op: DydxWsOperation::Unsubscribe,
1035 channel: DydxWsChannel::Subaccounts,
1036 id: Some(id),
1037 };
1038 self.unsubscribe_pinned(topic, sub).await
1039 }
1040
1041 pub async fn subscribe_block_height(&self) -> DydxWsResult<()> {
1051 let topic = Self::topic(DydxWsChannel::BlockHeight, None);
1052 let sub = DydxSubscription {
1053 op: DydxWsOperation::Subscribe,
1054 channel: DydxWsChannel::BlockHeight,
1055 id: None,
1056 };
1057 self.subscribe_pinned(topic, sub).await
1058 }
1059
1060 pub async fn unsubscribe_block_height(&self) -> DydxWsResult<()> {
1066 let topic = Self::topic(DydxWsChannel::BlockHeight, None);
1067 let sub = DydxSubscription {
1068 op: DydxWsOperation::Unsubscribe,
1069 channel: DydxWsChannel::BlockHeight,
1070 id: None,
1071 };
1072 self.unsubscribe_pinned(topic, sub).await
1073 }
1074
1075 async fn subscribe_pinned(&self, topic: String, sub_msg: DydxSubscription) -> DydxWsResult<()> {
1076 let _connect_guard = self.connect_lock.lock().await;
1077
1078 {
1079 let mut slots = self.slots.lock().expect("slots lock poisoned");
1080 if let Some(slot) = slots.iter_mut().find(|s| s.topics.contains_key(&topic)) {
1081 *slot.topics.get_mut(&topic).expect("topic refcount present") += 1;
1082 return Ok(());
1083 }
1084 }
1085
1086 if self.slots.lock().expect("slots lock poisoned").is_empty() {
1087 let new_slot = self.create_connection().await?;
1088 self.connection_mode.store(new_slot.connection_mode.clone());
1089 self.slots
1090 .lock()
1091 .expect("slots lock poisoned")
1092 .push(new_slot);
1093 }
1094
1095 let mut slots = self.slots.lock().expect("slots lock poisoned");
1096 let slot = slots.first_mut().expect("primary slot exists");
1097 slot.subscriptions_state.mark_subscribe(&topic);
1098 slot.cmd_tx
1099 .send(HandlerCommand::RegisterSubscription {
1100 topic: topic.clone(),
1101 subscription: sub_msg.clone(),
1102 })
1103 .map_err(|e| {
1104 slot.subscriptions_state.mark_failure(&topic);
1105 DydxWsError::Transport(format!("Primary slot unavailable: {e}"))
1106 })?;
1107 let payload = serde_json::to_string(&sub_msg)?;
1108 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
1109 slot.subscriptions_state.mark_failure(&topic);
1110 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
1111 topic: topic.clone(),
1112 });
1113 return Err(DydxWsError::Transport(format!(
1114 "Primary slot send failed: {e}"
1115 )));
1116 }
1117 slot.topics.insert(topic, 1);
1118 Ok(())
1119 }
1120
1121 async fn unsubscribe_pinned(
1122 &self,
1123 topic: String,
1124 unsub_msg: DydxSubscription,
1125 ) -> DydxWsResult<()> {
1126 let mut slots = self.slots.lock().expect("slots lock poisoned");
1127 let Some(slot) = slots.first_mut() else {
1128 return Ok(());
1129 };
1130 let Some(refcount) = slot.topics.get_mut(&topic) else {
1131 return Ok(());
1132 };
1133
1134 if *refcount > 1 {
1135 *refcount -= 1;
1136 return Ok(());
1137 }
1138 slot.subscriptions_state.mark_unsubscribe(&topic);
1139 let payload = serde_json::to_string(&unsub_msg)?;
1140 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
1141 slot.subscriptions_state.mark_subscribe(&topic);
1142 return Err(DydxWsError::Transport(format!(
1143 "Primary slot send failed: {e}"
1144 )));
1145 }
1146 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
1147 topic: topic.clone(),
1148 });
1149 slot.topics.remove(&topic);
1150 Ok(())
1151 }
1152}
1153
1154pub(crate) fn candle_ids_from_topics(topics: &[String]) -> AHashSet<String> {
1158 let prefix = format!(
1159 "{}{}",
1160 DydxWsChannel::Candles.as_ref(),
1161 DYDX_WS_TOPIC_DELIMITER
1162 );
1163 topics
1164 .iter()
1165 .filter_map(|topic| topic.strip_prefix(&prefix).map(ToString::to_string))
1166 .collect()
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use rstest::rstest;
1172
1173 use super::*;
1174
1175 #[rstest]
1176 fn test_candle_ids_from_topics_extracts_only_candle_ids() {
1177 let topics = vec![
1178 "v4_candles:BTC-USD/1MIN".to_string(),
1179 "v4_trades:BTC-USD".to_string(),
1180 "v4_orderbook:ETH-USD".to_string(),
1181 "v4_candles:ETH-USD/5MINS".to_string(),
1182 ];
1183
1184 let ids = candle_ids_from_topics(&topics);
1185
1186 assert_eq!(ids.len(), 2);
1187 assert!(ids.contains("BTC-USD/1MIN"));
1188 assert!(ids.contains("ETH-USD/5MINS"));
1189 assert!(!ids.contains("BTC-USD"));
1190 }
1191}