1use std::{
19 fmt::Debug,
20 num::NonZeroU32,
21 sync::{
22 Arc,
23 atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering},
24 },
25 time::Duration,
26};
27
28use ahash::AHashSet;
29use arc_swap::ArcSwap;
30use nautilus_core::{AtomicMap, consts::NAUTILUS_USER_AGENT};
31use nautilus_live::{
32 SocketControl,
33 task::{SharedTaskSlot, TaskJoinOutcome},
34};
35use nautilus_network::{
36 http::USER_AGENT,
37 mode::ConnectionMode,
38 websocket::{
39 InitialConnectRetryPolicy, PingHandler, ReconnectHeaders, SubscriptionState,
40 TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
41 },
42};
43use parking_lot::Mutex;
44use tokio_util::sync::CancellationToken;
45use ustr::Ustr;
46
47use super::{
48 AxMdSubscriptionSpec,
49 handler::{AxMdWsFeedHandler, HandlerCommand},
50};
51use crate::{
52 common::enums::{AxCandleWidth, AxMarketDataLevel},
53 websocket::messages::AxDataWsMessage,
54};
55
56const AX_TOPIC_DELIMITER: char = ':';
58
59pub type AxWsResult<T> = Result<T, AxWsClientError>;
61
62#[derive(Debug, Clone)]
64pub enum AxWsClientError {
65 Transport(String),
67 ChannelError(String),
69}
70
71impl core::fmt::Display for AxWsClientError {
72 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
73 match self {
74 Self::Transport(msg) => write!(f, "Transport error: {msg}"),
75 Self::ChannelError(msg) => write!(f, "Channel error: {msg}"),
76 }
77 }
78}
79
80impl std::error::Error for AxWsClientError {}
81
82#[derive(Debug, Default, Clone)]
83pub struct SymbolDataTypes {
84 pub quotes: bool,
85 pub trades: bool,
86 pub mark_prices: bool,
87 pub instrument_status: bool,
88 pub book_level: Option<AxMarketDataLevel>,
89}
90
91impl SymbolDataTypes {
92 fn effective_subscription(&self) -> Option<AxMdSubscriptionSpec> {
93 let ticker = self.mark_prices || self.instrument_status;
94 let book_level = self.book_level.or({
95 if self.quotes || ticker {
96 Some(AxMarketDataLevel::Level1)
97 } else {
98 None
99 }
100 });
101
102 if let Some(level) = book_level {
103 return Some(AxMdSubscriptionSpec::new(
104 level,
105 Some(self.trades),
106 Some(ticker),
107 ));
108 }
109
110 if self.trades {
111 return Some(AxMdSubscriptionSpec::new(
112 AxMarketDataLevel::Trades,
113 None,
114 None,
115 ));
116 }
117
118 None
119 }
120
121 fn is_empty(&self) -> bool {
122 !self.quotes
123 && !self.trades
124 && !self.mark_prices
125 && !self.instrument_status
126 && self.book_level.is_none()
127 }
128}
129
130pub struct AxMdWebSocketClient {
135 url: String,
136 heartbeat: Option<u64>,
137 auth_token: Arc<Mutex<Option<String>>>,
138 reconnect_headers: Arc<Mutex<Option<ReconnectHeaders>>>,
139 connection_mode: Arc<ArcSwap<AtomicU8>>,
140 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
141 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<AxDataWsMessage>>>,
142 signal: Arc<AtomicBool>,
143 cancellation_token: Arc<ArcSwap<CancellationToken>>,
144 task_handle: Arc<SharedTaskSlot<()>>,
145 connect_lock: Arc<tokio::sync::Mutex<()>>,
146 subscriptions: SubscriptionState,
147 request_id_counter: Arc<AtomicI64>,
148 subscribe_lock: Arc<tokio::sync::Mutex<()>>,
149 symbol_data_types: Arc<AtomicMap<String, SymbolDataTypes>>,
150 status_invalidations: Arc<Mutex<AHashSet<Ustr>>>,
151 transport_backend: TransportBackend,
152 proxy_url: Option<String>,
153 socket_control: Option<SocketControl>,
154}
155
156impl Debug for AxMdWebSocketClient {
157 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
158 f.debug_struct(stringify!(AxMdWebSocketClient))
159 .field("url", &self.url)
160 .field("heartbeat", &self.heartbeat)
161 .field("confirmed_subscriptions", &self.subscriptions.len())
162 .finish()
163 }
164}
165
166impl Clone for AxMdWebSocketClient {
167 fn clone(&self) -> Self {
168 Self {
169 url: self.url.clone(),
170 heartbeat: self.heartbeat,
171 auth_token: Arc::clone(&self.auth_token),
172 reconnect_headers: Arc::clone(&self.reconnect_headers),
173 connection_mode: Arc::clone(&self.connection_mode),
174 cmd_tx: Arc::clone(&self.cmd_tx),
175 out_rx: None,
176 signal: Arc::clone(&self.signal),
177 cancellation_token: Arc::clone(&self.cancellation_token),
178 task_handle: Arc::clone(&self.task_handle),
179 connect_lock: Arc::clone(&self.connect_lock),
180 subscriptions: self.subscriptions.clone(),
181 subscribe_lock: Arc::clone(&self.subscribe_lock),
182 request_id_counter: Arc::clone(&self.request_id_counter),
183 symbol_data_types: Arc::clone(&self.symbol_data_types),
184 status_invalidations: Arc::clone(&self.status_invalidations),
185 transport_backend: self.transport_backend,
186 proxy_url: self.proxy_url.clone(),
187 socket_control: self.socket_control.clone(),
188 }
189 }
190}
191
192impl AxMdWebSocketClient {
193 fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
194 InitialConnectRetryPolicy {
195 max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
196 delay_initial: Duration::from_millis(500),
197 delay_max: Duration::from_secs(5),
198 backoff_factor: 2.0,
199 jitter_ms: 250,
200 }
201 }
202
203 #[must_use]
207 pub fn new(
208 url: String,
209 auth_token: String,
210 heartbeat: u64,
211 transport_backend: TransportBackend,
212 proxy_url: Option<String>,
213 ) -> Self {
214 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
215
216 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
217 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
218
219 Self {
220 url,
221 heartbeat: Some(heartbeat),
222 auth_token: Arc::new(Mutex::new(Some(auth_token))),
223 reconnect_headers: Arc::new(Mutex::new(None)),
224 connection_mode,
225 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
226 out_rx: None,
227 signal: Arc::new(AtomicBool::new(false)),
228 cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
229 task_handle: Arc::new(SharedTaskSlot::new()),
230 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
231 subscriptions: SubscriptionState::new(AX_TOPIC_DELIMITER),
232 request_id_counter: Arc::new(AtomicI64::new(1)),
233 subscribe_lock: Arc::new(tokio::sync::Mutex::new(())),
234 symbol_data_types: Arc::new(AtomicMap::new()),
235 status_invalidations: Arc::new(Mutex::new(AHashSet::new())),
236 transport_backend,
237 proxy_url,
238 socket_control: None,
239 }
240 }
241
242 #[must_use]
246 pub fn without_auth(
247 url: String,
248 heartbeat: u64,
249 transport_backend: TransportBackend,
250 proxy_url: Option<String>,
251 ) -> Self {
252 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
253
254 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
255 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
256
257 Self {
258 url,
259 heartbeat: Some(heartbeat),
260 auth_token: Arc::new(Mutex::new(None)),
261 reconnect_headers: Arc::new(Mutex::new(None)),
262 connection_mode,
263 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
264 out_rx: None,
265 signal: Arc::new(AtomicBool::new(false)),
266 cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
267 task_handle: Arc::new(SharedTaskSlot::new()),
268 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
269 subscriptions: SubscriptionState::new(AX_TOPIC_DELIMITER),
270 request_id_counter: Arc::new(AtomicI64::new(1)),
271 subscribe_lock: Arc::new(tokio::sync::Mutex::new(())),
272 symbol_data_types: Arc::new(AtomicMap::new()),
273 status_invalidations: Arc::new(Mutex::new(AHashSet::new())),
274 transport_backend,
275 proxy_url,
276 socket_control: None,
277 }
278 }
279
280 #[must_use]
282 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
283 self.socket_control = Some(control);
284 self
285 }
286
287 #[must_use]
289 pub fn url(&self) -> &str {
290 &self.url
291 }
292
293 pub fn set_auth_token(&self, token: String) {
297 *self.auth_token.lock() = Some(token);
298 }
299
300 pub fn update_auth_token(&self, token: String) -> AxWsResult<()> {
308 let value = format!("Bearer {token}");
309
310 if let Some(headers) = self.reconnect_headers.lock().as_ref() {
311 headers
312 .update("Authorization", &value)
313 .map_err(|e| AxWsClientError::Transport(e.to_string()))?;
314 }
315 self.set_auth_token(token);
316 Ok(())
317 }
318
319 #[must_use]
321 pub fn is_active(&self) -> bool {
322 let connection_mode_arc = self.connection_mode.load();
323 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
324 && !self.signal.load(Ordering::Acquire)
325 }
326
327 #[must_use]
329 pub fn is_closed(&self) -> bool {
330 let connection_mode_arc = self.connection_mode.load();
331 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
332 || self.signal.load(Ordering::Acquire)
333 }
334
335 #[must_use]
337 pub fn subscription_count(&self) -> usize {
338 self.subscriptions.len()
339 }
340
341 #[must_use]
343 pub fn symbol_data_types(&self) -> Arc<AtomicMap<String, SymbolDataTypes>> {
344 Arc::clone(&self.symbol_data_types)
345 }
346
347 pub fn status_invalidations(&self) -> Arc<Mutex<AHashSet<Ustr>>> {
349 Arc::clone(&self.status_invalidations)
350 }
351
352 fn next_request_id(&self) -> i64 {
353 self.request_id_counter.fetch_add(1, Ordering::Relaxed)
354 }
355
356 fn is_subscribed_topic(&self, topic: &str) -> bool {
357 let (channel, symbol) = topic
358 .split_once(AX_TOPIC_DELIMITER)
359 .map_or((topic, None), |(c, s)| (c, Some(s)));
360 let channel_ustr = Ustr::from(channel);
361 let symbol_ustr = symbol.map_or_else(|| Ustr::from(""), Ustr::from);
362 self.subscriptions
363 .is_subscribed(&channel_ustr, &symbol_ustr)
364 }
365
366 pub async fn connect(&mut self) -> AxWsResult<()> {
373 let connect_lock = Arc::clone(&self.connect_lock);
374 let _guard = connect_lock.lock().await;
375
376 if !self.task_handle.is_empty() && !self.task_handle.is_finished() {
377 return Err(AxWsClientError::Transport(
378 "WebSocket handler is already running".to_string(),
379 ));
380 }
381
382 if let Some(outcome) = self
383 .task_handle
384 .finish(Duration::from_secs(2), Duration::from_secs(2))
385 .await
386 {
387 match outcome {
388 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
389 TaskJoinOutcome::Failed(error) => {
390 return Err(AxWsClientError::Transport(format!(
391 "Previous WebSocket handler failed: {error}"
392 )));
393 }
394 TaskJoinOutcome::Incomplete => {
395 return Err(AxWsClientError::Transport(
396 "Previous WebSocket handler did not stop within shutdown bounds"
397 .to_string(),
398 ));
399 }
400 }
401 }
402
403 self.signal.store(false, Ordering::Release);
404 let cancellation_token = CancellationToken::new();
405 self.cancellation_token
406 .store(Arc::new(cancellation_token.clone()));
407
408 let (raw_handler, raw_rx) = channel_message_handler();
409
410 let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {});
412
413 let mut headers = vec![(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())];
414
415 let auth_token = self.auth_token.lock().clone();
416
417 if let Some(token) = auth_token {
418 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
419 }
420
421 let config = WebSocketConfig {
422 url: self.url.clone(),
423 headers,
424 heartbeat_interval_secs: self.heartbeat,
425 heartbeat_payload: None, connect_timeout_ms: Some(5_000),
427 reconnect_delay_initial_ms: Some(500),
428 reconnect_delay_max_ms: Some(5_000),
429 reconnect_backoff_factor: Some(1.5),
430 reconnect_jitter_ms: Some(250),
431 reconnect_max_attempts: None,
432 heartbeat_timeout_secs: None,
433 idle_timeout_ms: None,
434 backend: self.transport_backend,
435 proxy_url: self.proxy_url.clone(),
436 };
437
438 let client = WebSocketClient::builder()
439 .config(config.clone())
440 .message_handler(raw_handler.clone())
441 .ping_handler(ping_handler.clone())
442 .initial_connect_retry_policy(Self::initial_connect_retry_policy())
443 .cancellation_token(cancellation_token)
444 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
445 .connect()
446 .await
447 .map_err(|e| {
448 AxWsClientError::Transport(format!("Failed to connect to {}: {e}", self.url))
449 })?;
450
451 self.connection_mode.store(client.connection_mode_atomic());
452 let reconnect_handle = client.reconnect_handle();
453 *self.reconnect_headers.lock() = Some(client.reconnect_headers());
454
455 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<AxDataWsMessage>();
456 self.out_rx = Some(Arc::new(out_rx));
457
458 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
459 *self.cmd_tx.write().await = cmd_tx.clone();
460
461 self.send_cmd(HandlerCommand::SetClient(client)).await?;
462
463 let signal = Arc::clone(&self.signal);
464 let subscriptions = self.subscriptions.clone();
465
466 if let Err(e) = self.task_handle.spawn(async move {
467 let mut handler =
468 AxMdWsFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions.clone());
469
470 while let Some(msg) = handler.next().await {
471 if matches!(msg, AxDataWsMessage::Reconnected) {
472 log::info!("WebSocket reconnected, subscriptions will be replayed");
473 }
474
475 if out_tx.send(msg).is_err() {
476 log::debug!("Output channel closed");
477 break;
478 }
479 }
480
481 log::debug!("Handler loop exited");
482 }) {
483 self.out_rx = None;
484 return Err(AxWsClientError::Transport(format!(
485 "Failed to start WebSocket handler task: {e}"
486 )));
487 }
488
489 if let Some(control) = &self.socket_control {
490 control.register(move || reconnect_handle.request_reconnect());
491 }
492
493 Ok(())
494 }
495
496 pub async fn subscribe_book_deltas(
505 &self,
506 symbol: &str,
507 level: AxMarketDataLevel,
508 ) -> AxWsResult<()> {
509 let _guard = self.subscribe_lock.lock().await;
510
511 let current = self
512 .symbol_data_types
513 .load()
514 .get(symbol)
515 .cloned()
516 .unwrap_or_default();
517
518 if current.book_level == Some(level) {
519 log::debug!("Book deltas already subscribed for {symbol} at {level:?}, skipping");
520 return Ok(());
521 }
522
523 let old_spec = current.effective_subscription();
524 let mut next = current.clone();
525 next.book_level = Some(level);
526 let new_spec = next.effective_subscription();
527
528 self.update_data_subscription(symbol, old_spec, new_spec)
529 .await?;
530
531 self.symbol_data_types.rcu(|m| {
532 let entry = m.entry(symbol.to_string()).or_default();
533 entry.book_level = Some(level);
534 });
535
536 Ok(())
537 }
538
539 pub async fn subscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
548 let _guard = self.subscribe_lock.lock().await;
549
550 let current = self
551 .symbol_data_types
552 .load()
553 .get(symbol)
554 .cloned()
555 .unwrap_or_default();
556 let old_spec = current.effective_subscription();
557 let mut next = current.clone();
558 next.quotes = true;
559 let new_spec = next.effective_subscription();
560
561 self.update_data_subscription(symbol, old_spec, new_spec)
562 .await?;
563
564 self.symbol_data_types.rcu(|m| {
565 m.entry(symbol.to_string()).or_default().quotes = true;
566 });
567
568 Ok(())
569 }
570
571 pub async fn subscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
580 let _guard = self.subscribe_lock.lock().await;
581
582 let current = self
583 .symbol_data_types
584 .load()
585 .get(symbol)
586 .cloned()
587 .unwrap_or_default();
588 let old_spec = current.effective_subscription();
589 let mut next = current.clone();
590 next.trades = true;
591 let new_spec = next.effective_subscription();
592
593 self.update_data_subscription(symbol, old_spec, new_spec)
594 .await?;
595
596 self.symbol_data_types.rcu(|m| {
597 m.entry(symbol.to_string()).or_default().trades = true;
598 });
599
600 Ok(())
601 }
602
603 pub async fn unsubscribe_book_deltas(&self, symbol: &str) -> AxWsResult<()> {
612 let _guard = self.subscribe_lock.lock().await;
613
614 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
615 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe book deltas");
616 return Ok(());
617 };
618 let old_spec = current.effective_subscription();
619 let mut next = current.clone();
620 next.book_level = None;
621 let new_spec = next.effective_subscription();
622
623 self.update_data_subscription(symbol, old_spec, new_spec)
624 .await?;
625
626 self.symbol_data_types.rcu(|m| {
627 if let Some(entry) = m.get_mut(symbol) {
628 entry.book_level = None;
629 if entry.is_empty() {
630 m.remove(symbol);
631 }
632 }
633 });
634
635 Ok(())
636 }
637
638 pub async fn unsubscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
647 let _guard = self.subscribe_lock.lock().await;
648
649 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
650 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe quotes");
651 return Ok(());
652 };
653 let old_spec = current.effective_subscription();
654 let mut next = current.clone();
655 next.quotes = false;
656 let new_spec = next.effective_subscription();
657
658 self.update_data_subscription(symbol, old_spec, new_spec)
659 .await?;
660
661 self.symbol_data_types.rcu(|m| {
662 if let Some(entry) = m.get_mut(symbol) {
663 entry.quotes = false;
664 if entry.is_empty() {
665 m.remove(symbol);
666 }
667 }
668 });
669
670 Ok(())
671 }
672
673 pub async fn unsubscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
682 let _guard = self.subscribe_lock.lock().await;
683
684 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
685 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe trades");
686 return Ok(());
687 };
688 let old_spec = current.effective_subscription();
689 let mut next = current.clone();
690 next.trades = false;
691 let new_spec = next.effective_subscription();
692
693 self.update_data_subscription(symbol, old_spec, new_spec)
694 .await?;
695
696 self.symbol_data_types.rcu(|m| {
697 if let Some(entry) = m.get_mut(symbol) {
698 entry.trades = false;
699 if entry.is_empty() {
700 m.remove(symbol);
701 }
702 }
703 });
704
705 Ok(())
706 }
707
708 pub async fn subscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
717 let _guard = self.subscribe_lock.lock().await;
718
719 let current = self
720 .symbol_data_types
721 .load()
722 .get(symbol)
723 .cloned()
724 .unwrap_or_default();
725 let old_spec = current.effective_subscription();
726 let mut next = current.clone();
727 next.mark_prices = true;
728 let new_spec = next.effective_subscription();
729
730 self.update_data_subscription(symbol, old_spec, new_spec)
731 .await?;
732
733 self.symbol_data_types.rcu(|m| {
734 m.entry(symbol.to_string()).or_default().mark_prices = true;
735 });
736
737 Ok(())
738 }
739
740 pub async fn unsubscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
749 let _guard = self.subscribe_lock.lock().await;
750
751 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
752 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe mark prices");
753 return Ok(());
754 };
755 let old_spec = current.effective_subscription();
756 let mut next = current.clone();
757 next.mark_prices = false;
758 let new_spec = next.effective_subscription();
759
760 self.update_data_subscription(symbol, old_spec, new_spec)
761 .await?;
762
763 self.symbol_data_types.rcu(|m| {
764 if let Some(entry) = m.get_mut(symbol) {
765 entry.mark_prices = false;
766 if entry.is_empty() {
767 m.remove(symbol);
768 }
769 }
770 });
771
772 Ok(())
773 }
774
775 pub async fn subscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
784 let _guard = self.subscribe_lock.lock().await;
785
786 let current = self
787 .symbol_data_types
788 .load()
789 .get(symbol)
790 .cloned()
791 .unwrap_or_default();
792 let old_spec = current.effective_subscription();
793 let mut next = current.clone();
794 next.instrument_status = true;
795 let new_spec = next.effective_subscription();
796
797 self.update_data_subscription(symbol, old_spec, new_spec)
798 .await?;
799
800 self.symbol_data_types.rcu(|m| {
801 m.entry(symbol.to_string()).or_default().instrument_status = true;
802 });
803
804 Ok(())
805 }
806
807 pub async fn unsubscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
816 let _guard = self.subscribe_lock.lock().await;
817
818 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
819 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe instrument status");
820 return Ok(());
821 };
822 let old_spec = current.effective_subscription();
823 let mut next = current.clone();
824 next.instrument_status = false;
825 let new_spec = next.effective_subscription();
826
827 self.update_data_subscription(symbol, old_spec, new_spec)
828 .await?;
829
830 self.symbol_data_types.rcu(|m| {
831 if let Some(entry) = m.get_mut(symbol) {
832 entry.instrument_status = false;
833 if entry.is_empty() {
834 m.remove(symbol);
835 }
836 }
837 });
838
839 self.status_invalidations.lock().insert(Ustr::from(symbol));
840
841 Ok(())
842 }
843
844 async fn update_data_subscription(
845 &self,
846 symbol: &str,
847 old_spec: Option<AxMdSubscriptionSpec>,
848 new_spec: Option<AxMdSubscriptionSpec>,
849 ) -> AxWsResult<()> {
850 if old_spec == new_spec {
851 return Ok(());
852 }
853
854 match (old_spec, new_spec) {
855 (None, Some(spec)) => {
856 log::debug!("Subscribing {symbol} at {spec:?}");
857 self.send_subscribe(symbol, spec).await
858 }
859 (Some(old), None) => {
860 log::debug!("Unsubscribing {symbol} (no remaining data types)");
861 self.send_unsubscribe(symbol, old).await
862 }
863 (Some(old), Some(new)) => {
864 log::debug!("Resubscribing {symbol}: {old:?} -> {new:?}");
865 self.send_unsubscribe(symbol, old).await?;
866 if let Err(e) = self.send_subscribe(symbol, new).await {
867 log::warn!("Resubscribe failed for {symbol} at {new:?}: {e}");
868 if let Err(restore_err) = self.send_subscribe(symbol, old).await {
869 log::error!(
870 "Failed to restore {symbol} at {old:?}: {restore_err}, \
871 reconnection required"
872 );
873 self.subscriptions.mark_subscribe(&old.topic(symbol));
874 }
875 return Err(e);
876 }
877 Ok(())
878 }
879 (None, None) => Ok(()),
880 }
881 }
882
883 async fn send_subscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
884 let topic = spec.topic(symbol);
885 let request_id = self.next_request_id();
886
887 self.subscriptions.mark_subscribe(&topic);
888
889 if let Err(e) = self
890 .send_cmd(HandlerCommand::Subscribe {
891 request_id,
892 symbol: Ustr::from(symbol),
893 spec,
894 })
895 .await
896 {
897 self.subscriptions.mark_unsubscribe(&topic);
898 return Err(e);
899 }
900
901 Ok(())
902 }
903
904 async fn send_unsubscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
905 let request_id = self.next_request_id();
906 let topic = spec.topic(symbol);
907 let was_pending = self
908 .subscriptions
909 .pending_subscribe_topics()
910 .contains(&topic);
911
912 self.subscriptions.mark_unsubscribe(&topic);
913
914 if let Err(e) = self
915 .send_cmd(HandlerCommand::Unsubscribe {
916 request_id,
917 symbol: Ustr::from(symbol),
918 topic: topic.clone(),
919 })
920 .await
921 {
922 self.restore_unsubscribe_state(&topic, was_pending);
923 return Err(e);
924 }
925
926 Ok(())
927 }
928
929 pub async fn subscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
937 let _guard = self.subscribe_lock.lock().await;
938 let topic = format!("candles:{symbol}:{width:?}");
939
940 if self.is_subscribed_topic(&topic) {
942 log::debug!("Already subscribed to {topic}, skipping");
943 return Ok(());
944 }
945
946 let request_id = self.next_request_id();
947
948 self.subscriptions.mark_subscribe(&topic);
950
951 if let Err(e) = self
952 .send_cmd(HandlerCommand::SubscribeCandles {
953 request_id,
954 symbol: Ustr::from(symbol),
955 width,
956 })
957 .await
958 {
959 self.subscriptions.mark_unsubscribe(&topic);
961 return Err(e);
962 }
963
964 Ok(())
965 }
966
967 pub async fn unsubscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
973 let _guard = self.subscribe_lock.lock().await;
974 let request_id = self.next_request_id();
975 let topic = format!("candles:{symbol}:{width:?}");
976 let was_pending = self
977 .subscriptions
978 .pending_subscribe_topics()
979 .contains(&topic);
980
981 if !self.is_subscribed_topic(&topic) {
982 log::debug!("Not subscribed to {topic}, skipping unsubscribe");
983 return Ok(());
984 }
985
986 self.subscriptions.mark_unsubscribe(&topic);
987
988 if let Err(e) = self
989 .send_cmd(HandlerCommand::UnsubscribeCandles {
990 request_id,
991 symbol: Ustr::from(symbol),
992 width,
993 topic: topic.clone(),
994 })
995 .await
996 {
997 self.restore_unsubscribe_state(&topic, was_pending);
998 return Err(e);
999 }
1000
1001 Ok(())
1002 }
1003
1004 fn restore_unsubscribe_state(&self, topic: &str, was_pending: bool) {
1005 self.subscriptions.confirm_unsubscribe(topic);
1006 self.subscriptions.mark_subscribe(topic);
1007 if !was_pending {
1008 self.subscriptions.confirm_subscribe(topic);
1009 }
1010 }
1011
1012 pub fn stream(&mut self) -> impl futures_util::Stream<Item = AxDataWsMessage> + 'static {
1018 let rx = self
1019 .out_rx
1020 .take()
1021 .expect("Stream receiver already taken or client not connected - stream() can only be called once");
1022 let mut rx = Arc::try_unwrap(rx).expect(
1023 "Cannot take ownership of stream - client was cloned and other references exist",
1024 );
1025 async_stream::stream! {
1026 while let Some(msg) = rx.recv().await {
1027 yield msg;
1028 }
1029 }
1030 }
1031
1032 pub(crate) fn begin_shutdown(&self) {
1033 self.cancellation_token.load().cancel();
1034 self.signal.store(true, Ordering::Release);
1035 }
1036
1037 pub async fn disconnect(&self) {
1039 log::debug!("Disconnecting WebSocket");
1040 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1041 }
1042
1043 pub async fn close(&mut self) -> anyhow::Result<()> {
1049 let connect_lock = Arc::clone(&self.connect_lock);
1050 let _guard = connect_lock.lock().await;
1051 log::debug!("Closing WebSocket client");
1052
1053 self.cancellation_token.load().cancel();
1055 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1056 tokio::time::sleep(Duration::from_millis(50)).await;
1057 self.signal.store(true, Ordering::Release);
1058
1059 let outcome = self
1060 .task_handle
1061 .finish(Duration::from_secs(2), Duration::from_secs(2))
1062 .await;
1063
1064 *self.reconnect_headers.lock() = None;
1065
1066 if let Some(control) = &self.socket_control {
1067 control.deregister();
1068 }
1069
1070 match outcome {
1071 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
1072 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
1073 "Architect AX data WebSocket handler failed: {error}"
1074 )),
1075 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
1076 "Architect AX data WebSocket handler did not stop after abort"
1077 )),
1078 }
1079 }
1080
1081 async fn send_cmd(&self, cmd: HandlerCommand) -> AxWsResult<()> {
1082 let guard = self.cmd_tx.read().await;
1083 guard
1084 .send(cmd)
1085 .map_err(|e| AxWsClientError::ChannelError(e.to_string()))
1086 }
1087}
1088
1089impl Drop for AxMdWebSocketClient {
1090 fn drop(&mut self) {
1091 if Arc::strong_count(&self.task_handle) == 1 && !self.task_handle.is_empty() {
1092 self.cancellation_token.load().cancel();
1093 self.signal.store(true, Ordering::Release);
1094 self.task_handle.abort();
1095
1096 if let Some(control) = &self.socket_control {
1097 control.deregister();
1098 }
1099 }
1100 }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use rstest::rstest;
1106
1107 use super::*;
1108
1109 #[tokio::test]
1110 async fn test_drop_aborts_handler_task() {
1111 let client = AxMdWebSocketClient::new(
1112 "ws://localhost:9999/md/ws".to_string(),
1113 "test_token".to_string(),
1114 30,
1115 TransportBackend::default(),
1116 None,
1117 );
1118 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1119 let handle = tokio::spawn(async move {
1120 started_tx.send(()).expect("started receiver");
1121 std::future::pending::<()>().await;
1122 });
1123 let abort_handle = handle.abort_handle();
1124 client.task_handle.insert(handle);
1125 started_rx.await.expect("handler task started");
1126
1127 drop(client);
1128
1129 tokio::time::timeout(Duration::from_secs(1), async {
1130 while !abort_handle.is_finished() {
1131 tokio::task::yield_now().await;
1132 }
1133 })
1134 .await
1135 .expect("handler task aborted");
1136 }
1137
1138 #[rstest]
1139 fn test_effective_subscription_empty_returns_none() {
1140 let sdt = SymbolDataTypes::default();
1141 assert_eq!(sdt.effective_subscription(), None);
1142 assert!(sdt.is_empty());
1143 }
1144
1145 #[rstest]
1146 fn test_effective_subscription_book_level_takes_precedence() {
1147 let sdt = SymbolDataTypes {
1148 book_level: Some(AxMarketDataLevel::Level2),
1149 quotes: true,
1150 ..Default::default()
1151 };
1152 assert_eq!(
1153 sdt.effective_subscription(),
1154 Some(AxMdSubscriptionSpec::new(
1155 AxMarketDataLevel::Level2,
1156 Some(false),
1157 Some(false),
1158 ))
1159 );
1160 assert!(!sdt.is_empty());
1161 }
1162
1163 #[rstest]
1164 #[case(
1165 true,
1166 false,
1167 false,
1168 false,
1169 AxMarketDataLevel::Level1,
1170 Some(false),
1171 Some(false)
1172 )]
1173 #[case(false, true, false, false, AxMarketDataLevel::Trades, None, None)]
1174 #[case(
1175 false,
1176 false,
1177 true,
1178 false,
1179 AxMarketDataLevel::Level1,
1180 Some(false),
1181 Some(true)
1182 )]
1183 #[case(
1184 false,
1185 false,
1186 false,
1187 true,
1188 AxMarketDataLevel::Level1,
1189 Some(false),
1190 Some(true)
1191 )]
1192 fn test_effective_subscription_for_single_data_type(
1193 #[case] quotes: bool,
1194 #[case] trades: bool,
1195 #[case] mark_prices: bool,
1196 #[case] instrument_status: bool,
1197 #[case] level: AxMarketDataLevel,
1198 #[case] include_trades: Option<bool>,
1199 #[case] include_ticker: Option<bool>,
1200 ) {
1201 let sdt = SymbolDataTypes {
1202 quotes,
1203 trades,
1204 mark_prices,
1205 instrument_status,
1206 book_level: None,
1207 };
1208 assert_eq!(
1209 sdt.effective_subscription(),
1210 Some(AxMdSubscriptionSpec::new(
1211 level,
1212 include_trades,
1213 include_ticker,
1214 ))
1215 );
1216 assert!(!sdt.is_empty());
1217 }
1218
1219 #[rstest]
1220 #[case(false)]
1221 #[case(true)]
1222 #[tokio::test]
1223 async fn test_unsubscribe_send_failure_restores_subscription(#[case] was_pending: bool) {
1224 let client = AxMdWebSocketClient::new(
1225 "ws://localhost:9999/md/ws".to_string(),
1226 "test_token".to_string(),
1227 30,
1228 TransportBackend::default(),
1229 None,
1230 );
1231 let symbol = "EURUSD-PERP";
1232 let spec = AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(false));
1233 let topic = spec.topic(symbol);
1234 client.subscriptions.mark_subscribe(&topic);
1235 if !was_pending {
1236 client.subscriptions.confirm_subscribe(&topic);
1237 }
1238
1239 let error = client.send_unsubscribe(symbol, spec).await.unwrap_err();
1240
1241 assert_eq!(error.to_string(), "Channel error: channel closed");
1242 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1243 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1244 assert_eq!(
1245 client.subscriptions.pending_subscribe_topics().len(),
1246 usize::from(was_pending)
1247 );
1248 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1249 }
1250
1251 #[rstest]
1252 #[case(false)]
1253 #[case(true)]
1254 #[tokio::test]
1255 async fn test_unsubscribe_candles_send_failure_restores_subscription(
1256 #[case] was_pending: bool,
1257 ) {
1258 let client = AxMdWebSocketClient::new(
1259 "ws://localhost:9999/md/ws".to_string(),
1260 "test_token".to_string(),
1261 30,
1262 TransportBackend::default(),
1263 None,
1264 );
1265 let symbol = "EURUSD-PERP";
1266 let width = AxCandleWidth::Minutes1;
1267 let topic = format!("candles:{symbol}:{width:?}");
1268 client.subscriptions.mark_subscribe(&topic);
1269 if !was_pending {
1270 client.subscriptions.confirm_subscribe(&topic);
1271 }
1272
1273 let error = client.unsubscribe_candles(symbol, width).await.unwrap_err();
1274
1275 assert_eq!(error.to_string(), "Channel error: channel closed");
1276 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1277 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1278 assert_eq!(
1279 client.subscriptions.pending_subscribe_topics().len(),
1280 usize::from(was_pending)
1281 );
1282 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1283 }
1284
1285 #[rstest]
1286 #[tokio::test]
1287 async fn test_unsubscribe_candles_skips_untracked_topic() {
1288 let client = AxMdWebSocketClient::new(
1289 "ws://localhost:9999/md/ws".to_string(),
1290 "test_token".to_string(),
1291 30,
1292 TransportBackend::default(),
1293 None,
1294 );
1295
1296 client
1297 .unsubscribe_candles("EURUSD-PERP", AxCandleWidth::Minutes1)
1298 .await
1299 .unwrap();
1300
1301 assert_eq!(client.subscription_count(), 0);
1302 assert!(client.subscriptions.all_topics().is_empty());
1303 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1304 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1305 }
1306}