1use std::{
19 fmt::{Debug, Display},
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, string::secret::SecretString};
31use nautilus_live::{
32 SocketControl,
33 book::snapshot::SnapshotGate,
34 task::{SharedTaskSlot, TaskJoinOutcome},
35};
36use nautilus_network::{
37 ConnectionUrl,
38 http::create_standard_nautilus_headers,
39 mode::ConnectionMode,
40 websocket::{
41 InitialConnectRetryPolicy, PingHandler, ReconnectHeaders, SubscriptionState,
42 TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
43 },
44};
45use parking_lot::Mutex;
46use tokio_util::sync::CancellationToken;
47use ustr::Ustr;
48
49use super::{
50 AxMdSubscriptionSpec,
51 handler::{AxMdWsFeedHandler, HandlerCommand},
52};
53use crate::{
54 common::enums::{AxCandleWidth, AxMarketDataLevel},
55 websocket::messages::AxDataWsMessage,
56};
57
58const AX_TOPIC_DELIMITER: char = ':';
60
61pub type AxWsResult<T> = Result<T, AxWsClientError>;
63
64#[derive(Debug, Clone)]
66pub enum AxWsClientError {
67 Transport(String),
69 ChannelError(String),
71 ClientError(String),
73 OperationTimeout { timeout_ms: u64 },
75 InvalidSnapshot(String),
77}
78
79impl Display for AxWsClientError {
80 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81 match self {
82 Self::Transport(msg) => write!(f, "Transport error: {msg}"),
83 Self::ChannelError(msg) => write!(f, "Channel error: {msg}"),
84 Self::ClientError(msg) => write!(f, "Client error: {msg}"),
85 Self::OperationTimeout { timeout_ms } => {
86 write!(f, "Operation timed out after {timeout_ms}ms")
87 }
88 Self::InvalidSnapshot(msg) => write!(f, "Invalid book snapshot: {msg}"),
89 }
90 }
91}
92
93impl std::error::Error for AxWsClientError {}
94
95#[derive(Debug, Default, Clone)]
96pub struct SymbolDataTypes {
97 pub quotes: bool,
98 pub trades: bool,
99 pub mark_prices: bool,
100 pub instrument_status: bool,
101 pub book_level: Option<AxMarketDataLevel>,
102}
103
104impl SymbolDataTypes {
105 fn effective_subscription(&self) -> Option<AxMdSubscriptionSpec> {
106 let book_level = self.book_level.or({
107 if self.quotes || self.ticker() {
108 Some(AxMarketDataLevel::Level1)
109 } else {
110 None
111 }
112 });
113
114 if let Some(level) = book_level {
115 return Some(self.book_subscription(level));
116 }
117
118 if self.trades {
119 return Some(AxMdSubscriptionSpec::new(
120 AxMarketDataLevel::Trades,
121 None,
122 None,
123 ));
124 }
125
126 None
127 }
128
129 fn book_subscription(&self, level: AxMarketDataLevel) -> AxMdSubscriptionSpec {
130 AxMdSubscriptionSpec::new(level, Some(self.trades), Some(self.ticker()))
131 }
132
133 fn ticker(&self) -> bool {
134 self.mark_prices || self.instrument_status
135 }
136
137 fn is_empty(&self) -> bool {
138 !self.quotes
139 && !self.trades
140 && !self.mark_prices
141 && !self.instrument_status
142 && self.book_level.is_none()
143 }
144}
145
146pub struct AxMdWebSocketClient {
151 url: ConnectionUrl,
152 heartbeat: Option<u64>,
153 auth_token: Arc<Mutex<Option<SecretString>>>,
154 reconnect_headers: Arc<Mutex<Option<ReconnectHeaders>>>,
155 connection_mode: Arc<ArcSwap<AtomicU8>>,
156 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
157 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<AxDataWsMessage>>>,
158 signal: Arc<AtomicBool>,
159 cancellation_token: Arc<ArcSwap<CancellationToken>>,
160 task_handle: Arc<SharedTaskSlot<()>>,
161 connect_lock: Arc<tokio::sync::Mutex<()>>,
162 subscriptions: SubscriptionState,
163 request_id_counter: Arc<AtomicI64>,
164 subscribe_lock: Arc<tokio::sync::Mutex<()>>,
165 symbol_data_types: Arc<AtomicMap<String, SymbolDataTypes>>,
166 status_invalidations: Arc<Mutex<AHashSet<Ustr>>>,
167 transport_backend: TransportBackend,
168 proxy_url: Option<SecretString>,
169 socket_control: Option<SocketControl>,
170}
171
172impl Debug for AxMdWebSocketClient {
173 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174 f.debug_struct(stringify!(AxMdWebSocketClient))
175 .field("url", &self.url)
176 .field("heartbeat", &self.heartbeat)
177 .field("confirmed_subscriptions", &self.subscriptions.len())
178 .finish()
179 }
180}
181
182impl Clone for AxMdWebSocketClient {
183 fn clone(&self) -> Self {
184 Self {
185 url: self.url.clone(),
186 heartbeat: self.heartbeat,
187 auth_token: Arc::clone(&self.auth_token),
188 reconnect_headers: Arc::clone(&self.reconnect_headers),
189 connection_mode: Arc::clone(&self.connection_mode),
190 cmd_tx: Arc::clone(&self.cmd_tx),
191 out_rx: None,
192 signal: Arc::clone(&self.signal),
193 cancellation_token: Arc::clone(&self.cancellation_token),
194 task_handle: Arc::clone(&self.task_handle),
195 connect_lock: Arc::clone(&self.connect_lock),
196 subscriptions: self.subscriptions.clone(),
197 subscribe_lock: Arc::clone(&self.subscribe_lock),
198 request_id_counter: Arc::clone(&self.request_id_counter),
199 symbol_data_types: Arc::clone(&self.symbol_data_types),
200 status_invalidations: Arc::clone(&self.status_invalidations),
201 transport_backend: self.transport_backend,
202 proxy_url: self.proxy_url.clone(),
203 socket_control: self.socket_control.clone(),
204 }
205 }
206}
207
208impl AxMdWebSocketClient {
209 fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
210 InitialConnectRetryPolicy {
211 max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
212 delay_initial: Duration::from_millis(500),
213 delay_max: Duration::from_secs(5),
214 backoff_factor: 2.0,
215 jitter_ms: 250,
216 }
217 }
218
219 #[must_use]
223 pub fn new(
224 url: String,
225 auth_token: impl Into<SecretString>,
226 heartbeat: u64,
227 transport_backend: TransportBackend,
228 proxy_url: Option<String>,
229 ) -> Self {
230 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
231
232 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
233 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
234
235 Self {
236 url: url.into(),
237 heartbeat: Some(heartbeat),
238 auth_token: Arc::new(Mutex::new(Some(auth_token.into()))),
239 reconnect_headers: Arc::new(Mutex::new(None)),
240 connection_mode,
241 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
242 out_rx: None,
243 signal: Arc::new(AtomicBool::new(false)),
244 cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
245 task_handle: Arc::new(SharedTaskSlot::new()),
246 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
247 subscriptions: SubscriptionState::new(AX_TOPIC_DELIMITER),
248 request_id_counter: Arc::new(AtomicI64::new(1)),
249 subscribe_lock: Arc::new(tokio::sync::Mutex::new(())),
250 symbol_data_types: Arc::new(AtomicMap::new()),
251 status_invalidations: Arc::new(Mutex::new(AHashSet::new())),
252 transport_backend,
253 proxy_url: proxy_url.map(SecretString::from),
254 socket_control: None,
255 }
256 }
257
258 #[must_use]
262 pub fn without_auth(
263 url: String,
264 heartbeat: u64,
265 transport_backend: TransportBackend,
266 proxy_url: Option<String>,
267 ) -> Self {
268 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
269
270 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
271 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
272
273 Self {
274 url: url.into(),
275 heartbeat: Some(heartbeat),
276 auth_token: Arc::new(Mutex::new(None)),
277 reconnect_headers: Arc::new(Mutex::new(None)),
278 connection_mode,
279 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
280 out_rx: None,
281 signal: Arc::new(AtomicBool::new(false)),
282 cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
283 task_handle: Arc::new(SharedTaskSlot::new()),
284 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
285 subscriptions: SubscriptionState::new(AX_TOPIC_DELIMITER),
286 request_id_counter: Arc::new(AtomicI64::new(1)),
287 subscribe_lock: Arc::new(tokio::sync::Mutex::new(())),
288 symbol_data_types: Arc::new(AtomicMap::new()),
289 status_invalidations: Arc::new(Mutex::new(AHashSet::new())),
290 transport_backend,
291 proxy_url: proxy_url.map(SecretString::from),
292 socket_control: None,
293 }
294 }
295
296 #[must_use]
298 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
299 self.socket_control = Some(control);
300 self
301 }
302
303 #[must_use]
305 pub fn url(&self) -> &str {
306 self.url.expose()
307 }
308
309 pub fn set_auth_token(&self, token: impl Into<SecretString>) {
313 *self.auth_token.lock() = Some(token.into());
314 }
315
316 pub fn update_auth_token(&self, token: SecretString) -> AxWsResult<()> {
324 let value = format!("Bearer {}", token.expose_secret());
325
326 if let Some(headers) = self.reconnect_headers.lock().as_ref() {
327 headers
328 .update("Authorization", &value)
329 .map_err(|e| AxWsClientError::Transport(e.to_string()))?;
330 }
331 self.set_auth_token(token);
332 Ok(())
333 }
334
335 #[must_use]
337 pub fn is_active(&self) -> bool {
338 let connection_mode_arc = self.connection_mode.load();
339 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
340 && !self.signal.load(Ordering::Acquire)
341 }
342
343 #[must_use]
345 pub fn is_closed(&self) -> bool {
346 let connection_mode_arc = self.connection_mode.load();
347 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
348 || self.signal.load(Ordering::Acquire)
349 }
350
351 #[must_use]
353 pub fn subscription_count(&self) -> usize {
354 self.subscriptions.len()
355 }
356
357 #[must_use]
359 pub fn symbol_data_types(&self) -> Arc<AtomicMap<String, SymbolDataTypes>> {
360 Arc::clone(&self.symbol_data_types)
361 }
362
363 pub fn status_invalidations(&self) -> Arc<Mutex<AHashSet<Ustr>>> {
365 Arc::clone(&self.status_invalidations)
366 }
367
368 fn next_request_id(&self) -> i64 {
369 self.request_id_counter.fetch_add(1, Ordering::Relaxed)
370 }
371
372 fn is_subscribed_topic(&self, topic: &str) -> bool {
373 let (channel, symbol) = topic
374 .split_once(AX_TOPIC_DELIMITER)
375 .map_or((topic, None), |(c, s)| (c, Some(s)));
376 let channel_ustr = Ustr::from(channel);
377 let symbol_ustr = symbol.map_or_else(|| Ustr::from(""), Ustr::from);
378 self.subscriptions
379 .is_subscribed(&channel_ustr, &symbol_ustr)
380 }
381
382 pub async fn connect(&mut self) -> AxWsResult<()> {
389 let connect_lock = Arc::clone(&self.connect_lock);
390 let _guard = connect_lock.lock().await;
391
392 if !self.task_handle.is_empty() && !self.task_handle.is_finished() {
393 return Err(AxWsClientError::Transport(
394 "WebSocket handler is already running".to_string(),
395 ));
396 }
397
398 if let Some(outcome) = self
399 .task_handle
400 .finish(Duration::from_secs(2), Duration::from_secs(2))
401 .await
402 {
403 match outcome {
404 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
405 TaskJoinOutcome::Failed(error) => {
406 return Err(AxWsClientError::Transport(format!(
407 "Previous WebSocket handler failed: {error}"
408 )));
409 }
410 TaskJoinOutcome::Incomplete => {
411 return Err(AxWsClientError::Transport(
412 "Previous WebSocket handler did not stop within shutdown bounds"
413 .to_string(),
414 ));
415 }
416 }
417 }
418
419 self.signal.store(false, Ordering::Release);
420 let cancellation_token = CancellationToken::new();
421 self.cancellation_token
422 .store(Arc::new(cancellation_token.clone()));
423
424 let (raw_handler, raw_rx) = channel_message_handler();
425
426 let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {});
428
429 let mut headers = create_standard_nautilus_headers();
430
431 let auth_token = self.auth_token.lock().clone();
432
433 if let Some(token) = auth_token {
434 headers.push((
435 "Authorization".to_string(),
436 format!("Bearer {}", token.expose_secret()),
437 ));
438 }
439
440 let config = WebSocketConfig {
441 url: self.url.expose().to_owned(),
442 headers,
443 heartbeat_interval_secs: self.heartbeat,
444 heartbeat_payload: None, connect_timeout_ms: Some(5_000),
446 reconnect_delay_initial_ms: Some(500),
447 reconnect_delay_max_ms: Some(5_000),
448 reconnect_backoff_factor: Some(1.5),
449 reconnect_jitter_ms: Some(250),
450 reconnect_max_attempts: None,
451 heartbeat_timeout_secs: None,
452 idle_timeout_ms: None,
453 writer_capacity: None,
454 backend: self.transport_backend,
455 proxy_url: self
456 .proxy_url
457 .as_ref()
458 .map(|url| url.expose_secret().to_owned()),
459 max_message_size_bytes: None,
460 max_frame_size_bytes: None,
461 };
462
463 let client = WebSocketClient::builder()
464 .config(config.clone())
465 .message_handler(raw_handler.clone())
466 .ping_handler(ping_handler.clone())
467 .initial_connect_retry_policy(Self::initial_connect_retry_policy())
468 .cancellation_token(cancellation_token)
469 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
470 .connect()
471 .await
472 .map_err(|e| {
473 AxWsClientError::Transport(format!(
474 "WebSocket connection failed: {}: {}",
475 self.url.origin(),
476 e.redacted_message()
477 ))
478 })?;
479
480 self.connection_mode.store(client.connection_mode_atomic());
481 let reconnect_handle = client.reconnect_handle();
482 *self.reconnect_headers.lock() = Some(client.reconnect_headers());
483
484 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<AxDataWsMessage>();
485 self.out_rx = Some(Arc::new(out_rx));
486
487 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
488 *self.cmd_tx.write().await = cmd_tx.clone();
489
490 self.send_cmd(HandlerCommand::SetClient(client)).await?;
491
492 if !self.subscriptions.all_topics().is_empty() {
493 self.send_cmd(HandlerCommand::ReplaySubscriptions).await?;
494 }
495
496 let signal = Arc::clone(&self.signal);
497 let subscriptions = self.subscriptions.clone();
498
499 if let Err(e) = self.task_handle.spawn(async move {
500 let mut handler =
501 AxMdWsFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions.clone());
502
503 while let Some(msg) = handler.next().await {
504 if matches!(msg, AxDataWsMessage::Reconnected) {
505 log::info!("WebSocket reconnected, subscriptions will be replayed");
506 }
507
508 if out_tx.send(msg).is_err() {
509 log::debug!("Output channel closed");
510 break;
511 }
512 }
513
514 log::debug!("Handler loop exited");
515 }) {
516 self.out_rx = None;
517 return Err(AxWsClientError::Transport(format!(
518 "Failed to start WebSocket handler task: {e}"
519 )));
520 }
521
522 if let Some(control) = &self.socket_control {
523 control.register(move || reconnect_handle.request_reconnect());
524 }
525
526 Ok(())
527 }
528
529 pub async fn subscribe_book_deltas(
538 &self,
539 symbol: &str,
540 level: AxMarketDataLevel,
541 ) -> AxWsResult<()> {
542 let _guard = self.subscribe_lock.lock().await;
543
544 let current = self
545 .symbol_data_types
546 .load()
547 .get(symbol)
548 .cloned()
549 .unwrap_or_default();
550
551 if current.book_level == Some(level) {
552 log::debug!("Book deltas already subscribed for {symbol} at {level:?}, skipping");
553 return Ok(());
554 }
555
556 let old_spec = current.effective_subscription();
557 let mut next = current.clone();
558 next.book_level = Some(level);
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 let entry = m.entry(symbol.to_string()).or_default();
566 entry.book_level = Some(level);
567 });
568
569 Ok(())
570 }
571
572 pub(crate) async fn subscribe_book_deltas_gated(
579 &self,
580 symbol: Ustr,
581 level: AxMarketDataLevel,
582 cancel: CancellationToken,
583 gate: SnapshotGate,
584 ) -> AxWsResult<()> {
585 let (completion, receiver) = tokio::sync::oneshot::channel();
586
587 {
588 let _guard = self.subscribe_lock.lock().await;
589
590 if cancel.is_cancelled() {
591 return Ok(());
592 }
593
594 let current = self
595 .symbol_data_types
596 .load()
597 .get(symbol.as_str())
598 .cloned()
599 .unwrap_or_default();
600 let old_spec = current.effective_subscription();
601 let spec = current.book_subscription(level);
602 let replace = old_spec == Some(spec);
603
604 if let Some(old) = old_spec.filter(|_| !replace) {
605 log::debug!("Resubscribing {symbol}: {old:?} -> {spec:?}");
606 self.send_unsubscribe(symbol.as_str(), old).await?;
607 }
608
609 let topic = spec.topic(symbol.as_str());
610
611 if !replace {
612 self.subscriptions.mark_subscribe(&topic);
613 }
614
615 let command = HandlerCommand::WriteBook {
616 request_id: self.next_request_id(),
617 unsubscribe_request_id: replace.then(|| self.next_request_id()),
618 symbol,
619 spec,
620 cancel: cancel.clone(),
621 gate,
622 completion,
623 };
624
625 if let Err(e) = self.send_cmd(command).await {
626 if !replace {
627 self.subscriptions.mark_unsubscribe(&topic);
628 }
629
630 return Err(e);
631 }
632
633 self.symbol_data_types.rcu(|m| {
634 m.entry(symbol.to_string()).or_default().book_level = Some(level);
635 });
636 }
637
638 tokio::select! {
639 biased;
640 () = cancel.cancelled() => Ok(()),
641 result = receiver => result.map_err(|e| AxWsClientError::Transport(e.to_string()))?,
643 }
644 }
645
646 pub(crate) async fn resubscribe_book_gated(
652 &self,
653 symbol: Ustr,
654 cancel: CancellationToken,
655 gate: SnapshotGate,
656 ) -> AxWsResult<()> {
657 let (completion, receiver) = tokio::sync::oneshot::channel();
658
659 {
660 let _guard = self.subscribe_lock.lock().await;
662
663 if cancel.is_cancelled() {
664 return Err(AxWsClientError::ClientError(
665 "Book recovery canceled".to_string(),
666 ));
667 }
668
669 let Some(spec) = self
670 .symbol_data_types
671 .load()
672 .get(symbol.as_str())
673 .and_then(|types| types.book_level.map(|level| types.book_subscription(level)))
674 else {
675 return Err(AxWsClientError::ClientError(format!(
676 "Book deltas for {symbol} are no longer subscribed"
677 )));
678 };
679
680 self.send_cmd(HandlerCommand::WriteBook {
681 request_id: self.next_request_id(),
682 unsubscribe_request_id: Some(self.next_request_id()),
683 symbol,
684 spec,
685 cancel,
686 gate,
687 completion,
688 })
689 .await?;
690 }
691
692 receiver
693 .await
694 .map_err(|e| AxWsClientError::Transport(e.to_string()))?
695 }
696
697 pub async fn subscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
706 let _guard = self.subscribe_lock.lock().await;
707
708 let current = self
709 .symbol_data_types
710 .load()
711 .get(symbol)
712 .cloned()
713 .unwrap_or_default();
714 let old_spec = current.effective_subscription();
715 let mut next = current.clone();
716 next.quotes = true;
717 let new_spec = next.effective_subscription();
718
719 self.update_data_subscription(symbol, old_spec, new_spec)
720 .await?;
721
722 self.symbol_data_types.rcu(|m| {
723 m.entry(symbol.to_string()).or_default().quotes = true;
724 });
725
726 Ok(())
727 }
728
729 pub async fn subscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
738 let _guard = self.subscribe_lock.lock().await;
739
740 let current = self
741 .symbol_data_types
742 .load()
743 .get(symbol)
744 .cloned()
745 .unwrap_or_default();
746 let old_spec = current.effective_subscription();
747 let mut next = current.clone();
748 next.trades = true;
749 let new_spec = next.effective_subscription();
750
751 self.update_data_subscription(symbol, old_spec, new_spec)
752 .await?;
753
754 self.symbol_data_types.rcu(|m| {
755 m.entry(symbol.to_string()).or_default().trades = true;
756 });
757
758 Ok(())
759 }
760
761 pub async fn unsubscribe_book_deltas(&self, symbol: &str) -> AxWsResult<()> {
770 let _guard = self.subscribe_lock.lock().await;
771
772 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
773 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe book deltas");
774 return Ok(());
775 };
776 let old_spec = current.effective_subscription();
777 let mut next = current.clone();
778 next.book_level = None;
779 let new_spec = next.effective_subscription();
780
781 self.update_data_subscription(symbol, old_spec, new_spec)
782 .await?;
783
784 self.symbol_data_types.rcu(|m| {
785 if let Some(entry) = m.get_mut(symbol) {
786 entry.book_level = None;
787 if entry.is_empty() {
788 m.remove(symbol);
789 }
790 }
791 });
792
793 Ok(())
794 }
795
796 pub async fn unsubscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
805 let _guard = self.subscribe_lock.lock().await;
806
807 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
808 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe quotes");
809 return Ok(());
810 };
811 let old_spec = current.effective_subscription();
812 let mut next = current.clone();
813 next.quotes = false;
814 let new_spec = next.effective_subscription();
815
816 self.update_data_subscription(symbol, old_spec, new_spec)
817 .await?;
818
819 self.symbol_data_types.rcu(|m| {
820 if let Some(entry) = m.get_mut(symbol) {
821 entry.quotes = false;
822 if entry.is_empty() {
823 m.remove(symbol);
824 }
825 }
826 });
827
828 Ok(())
829 }
830
831 pub async fn unsubscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
840 let _guard = self.subscribe_lock.lock().await;
841
842 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
843 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe trades");
844 return Ok(());
845 };
846 let old_spec = current.effective_subscription();
847 let mut next = current.clone();
848 next.trades = false;
849 let new_spec = next.effective_subscription();
850
851 self.update_data_subscription(symbol, old_spec, new_spec)
852 .await?;
853
854 self.symbol_data_types.rcu(|m| {
855 if let Some(entry) = m.get_mut(symbol) {
856 entry.trades = false;
857 if entry.is_empty() {
858 m.remove(symbol);
859 }
860 }
861 });
862
863 Ok(())
864 }
865
866 pub async fn subscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
875 let _guard = self.subscribe_lock.lock().await;
876
877 let current = self
878 .symbol_data_types
879 .load()
880 .get(symbol)
881 .cloned()
882 .unwrap_or_default();
883 let old_spec = current.effective_subscription();
884 let mut next = current.clone();
885 next.mark_prices = true;
886 let new_spec = next.effective_subscription();
887
888 self.update_data_subscription(symbol, old_spec, new_spec)
889 .await?;
890
891 self.symbol_data_types.rcu(|m| {
892 m.entry(symbol.to_string()).or_default().mark_prices = true;
893 });
894
895 Ok(())
896 }
897
898 pub async fn unsubscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
907 let _guard = self.subscribe_lock.lock().await;
908
909 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
910 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe mark prices");
911 return Ok(());
912 };
913 let old_spec = current.effective_subscription();
914 let mut next = current.clone();
915 next.mark_prices = false;
916 let new_spec = next.effective_subscription();
917
918 self.update_data_subscription(symbol, old_spec, new_spec)
919 .await?;
920
921 self.symbol_data_types.rcu(|m| {
922 if let Some(entry) = m.get_mut(symbol) {
923 entry.mark_prices = false;
924 if entry.is_empty() {
925 m.remove(symbol);
926 }
927 }
928 });
929
930 Ok(())
931 }
932
933 pub async fn subscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
942 let _guard = self.subscribe_lock.lock().await;
943
944 let current = self
945 .symbol_data_types
946 .load()
947 .get(symbol)
948 .cloned()
949 .unwrap_or_default();
950 let old_spec = current.effective_subscription();
951 let mut next = current.clone();
952 next.instrument_status = true;
953 let new_spec = next.effective_subscription();
954
955 self.update_data_subscription(symbol, old_spec, new_spec)
956 .await?;
957
958 self.symbol_data_types.rcu(|m| {
959 m.entry(symbol.to_string()).or_default().instrument_status = true;
960 });
961
962 Ok(())
963 }
964
965 pub async fn unsubscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
974 let _guard = self.subscribe_lock.lock().await;
975
976 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
977 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe instrument status");
978 return Ok(());
979 };
980 let old_spec = current.effective_subscription();
981 let mut next = current.clone();
982 next.instrument_status = false;
983 let new_spec = next.effective_subscription();
984
985 self.update_data_subscription(symbol, old_spec, new_spec)
986 .await?;
987
988 self.symbol_data_types.rcu(|m| {
989 if let Some(entry) = m.get_mut(symbol) {
990 entry.instrument_status = false;
991 if entry.is_empty() {
992 m.remove(symbol);
993 }
994 }
995 });
996
997 self.status_invalidations.lock().insert(Ustr::from(symbol));
998
999 Ok(())
1000 }
1001
1002 async fn update_data_subscription(
1003 &self,
1004 symbol: &str,
1005 old_spec: Option<AxMdSubscriptionSpec>,
1006 new_spec: Option<AxMdSubscriptionSpec>,
1007 ) -> AxWsResult<()> {
1008 if old_spec == new_spec {
1009 return Ok(());
1010 }
1011
1012 match (old_spec, new_spec) {
1013 (None, Some(spec)) => {
1014 log::debug!("Subscribing {symbol} at {spec:?}");
1015 self.send_subscribe(symbol, spec).await
1016 }
1017 (Some(old), None) => {
1018 log::debug!("Unsubscribing {symbol} (no remaining data types)");
1019 self.send_unsubscribe(symbol, old).await
1020 }
1021 (Some(old), Some(new)) => {
1022 log::debug!("Resubscribing {symbol}: {old:?} -> {new:?}");
1023 self.send_unsubscribe(symbol, old).await?;
1024 if let Err(e) = self.send_subscribe(symbol, new).await {
1025 log::warn!("Resubscribe failed for {symbol} at {new:?}: {e}");
1026 if let Err(restore_err) = self.send_subscribe(symbol, old).await {
1027 log::error!(
1028 "Failed to restore {symbol} at {old:?}: {restore_err}, \
1029 reconnection required"
1030 );
1031 self.subscriptions.mark_subscribe(&old.topic(symbol));
1032 }
1033 return Err(e);
1034 }
1035 Ok(())
1036 }
1037 (None, None) => Ok(()),
1038 }
1039 }
1040
1041 async fn send_subscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
1042 let topic = spec.topic(symbol);
1043 let request_id = self.next_request_id();
1044
1045 self.subscriptions.mark_subscribe(&topic);
1046
1047 if let Err(e) = self
1048 .send_cmd(HandlerCommand::Subscribe {
1049 request_id,
1050 symbol: Ustr::from(symbol),
1051 spec,
1052 })
1053 .await
1054 {
1055 self.subscriptions.mark_unsubscribe(&topic);
1056 return Err(e);
1057 }
1058
1059 Ok(())
1060 }
1061
1062 async fn send_unsubscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
1063 let request_id = self.next_request_id();
1064 let topic = spec.topic(symbol);
1065 let was_pending = self
1066 .subscriptions
1067 .pending_subscribe_topics()
1068 .contains(&topic);
1069
1070 self.subscriptions.mark_unsubscribe(&topic);
1071
1072 if let Err(e) = self
1073 .send_cmd(HandlerCommand::Unsubscribe {
1074 request_id,
1075 symbol: Ustr::from(symbol),
1076 topic: topic.clone(),
1077 })
1078 .await
1079 {
1080 self.restore_unsubscribe_state(&topic, was_pending);
1081 return Err(e);
1082 }
1083
1084 Ok(())
1085 }
1086
1087 pub async fn subscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
1095 let _guard = self.subscribe_lock.lock().await;
1096 let topic = format!("candles:{symbol}:{width:?}");
1097
1098 if self.is_subscribed_topic(&topic) {
1100 log::debug!("Already subscribed to {topic}, skipping");
1101 return Ok(());
1102 }
1103
1104 let request_id = self.next_request_id();
1105
1106 self.subscriptions.mark_subscribe(&topic);
1108
1109 if let Err(e) = self
1110 .send_cmd(HandlerCommand::SubscribeCandles {
1111 request_id,
1112 symbol: Ustr::from(symbol),
1113 width,
1114 })
1115 .await
1116 {
1117 self.subscriptions.mark_unsubscribe(&topic);
1119 return Err(e);
1120 }
1121
1122 Ok(())
1123 }
1124
1125 pub async fn unsubscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
1131 let _guard = self.subscribe_lock.lock().await;
1132 let request_id = self.next_request_id();
1133 let topic = format!("candles:{symbol}:{width:?}");
1134 let was_pending = self
1135 .subscriptions
1136 .pending_subscribe_topics()
1137 .contains(&topic);
1138
1139 if !self.is_subscribed_topic(&topic) {
1140 log::debug!("Not subscribed to {topic}, skipping unsubscribe");
1141 return Ok(());
1142 }
1143
1144 self.subscriptions.mark_unsubscribe(&topic);
1145
1146 if let Err(e) = self
1147 .send_cmd(HandlerCommand::UnsubscribeCandles {
1148 request_id,
1149 symbol: Ustr::from(symbol),
1150 width,
1151 topic: topic.clone(),
1152 })
1153 .await
1154 {
1155 self.restore_unsubscribe_state(&topic, was_pending);
1156 return Err(e);
1157 }
1158
1159 Ok(())
1160 }
1161
1162 fn restore_unsubscribe_state(&self, topic: &str, was_pending: bool) {
1163 self.subscriptions.confirm_unsubscribe(topic);
1164 self.subscriptions.mark_subscribe(topic);
1165 if !was_pending {
1166 self.subscriptions.confirm_subscribe(topic);
1167 }
1168 }
1169
1170 pub fn stream(&mut self) -> impl futures_util::Stream<Item = AxDataWsMessage> + 'static {
1176 let rx = self
1177 .out_rx
1178 .take()
1179 .expect("Stream receiver already taken or client not connected - stream() can only be called once");
1180 let mut rx = Arc::try_unwrap(rx).expect(
1181 "Cannot take ownership of stream - client was cloned and other references exist",
1182 );
1183 async_stream::stream! {
1184 while let Some(msg) = rx.recv().await {
1185 yield msg;
1186 }
1187 }
1188 }
1189
1190 pub(crate) fn begin_shutdown(&self) {
1191 self.cancellation_token.load().cancel();
1192 self.signal.store(true, Ordering::Release);
1193 }
1194
1195 pub async fn disconnect(&self) {
1197 log::debug!("Disconnecting WebSocket");
1198 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1199 }
1200
1201 pub async fn close(&mut self) -> anyhow::Result<()> {
1207 let connect_lock = Arc::clone(&self.connect_lock);
1208 let _guard = connect_lock.lock().await;
1209 log::debug!("Closing WebSocket client");
1210
1211 self.cancellation_token.load().cancel();
1213 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1214 tokio::time::sleep(Duration::from_millis(50)).await;
1215 self.signal.store(true, Ordering::Release);
1216
1217 let outcome = self
1218 .task_handle
1219 .finish(Duration::from_secs(2), Duration::from_secs(2))
1220 .await;
1221
1222 *self.reconnect_headers.lock() = None;
1223
1224 if let Some(control) = &self.socket_control {
1225 control.deregister();
1226 }
1227
1228 match outcome {
1229 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
1230 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
1231 "Architect AX data WebSocket handler failed: {error}"
1232 )),
1233 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
1234 "Architect AX data WebSocket handler did not stop after abort"
1235 )),
1236 }
1237 }
1238
1239 async fn send_cmd(&self, cmd: HandlerCommand) -> AxWsResult<()> {
1240 let guard = self.cmd_tx.read().await;
1241 guard
1242 .send(cmd)
1243 .map_err(|e| AxWsClientError::ChannelError(e.to_string()))
1244 }
1245}
1246
1247impl Drop for AxMdWebSocketClient {
1248 fn drop(&mut self) {
1249 if Arc::strong_count(&self.task_handle) == 1 && !self.task_handle.is_empty() {
1250 self.cancellation_token.load().cancel();
1251 self.signal.store(true, Ordering::Release);
1252 self.task_handle.abort();
1253
1254 if let Some(control) = &self.socket_control {
1255 control.deregister();
1256 }
1257 }
1258 }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use nautilus_live::task::TaskGroup;
1264 use nautilus_model::identifiers::InstrumentId;
1265 use rstest::rstest;
1266
1267 use super::*;
1268 use crate::{
1269 book::{recovery::spawn_subscription_task, sync::BookSyncTracker},
1270 websocket::data::subscription::SubscriptionOrder,
1271 };
1272
1273 #[rstest]
1274 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
1275 #[case(
1276 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
1277 "wss://stream.example.com:9443"
1278 )]
1279 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
1280 let client = AxMdWebSocketClient::new(
1281 url.to_string(),
1282 "SYN-auth-token",
1283 30,
1284 TransportBackend::default(),
1285 None,
1286 );
1287 let debug = format!("{client:?}");
1288 assert!(debug.contains(&format!("url: {origin:?}")));
1289 assert!(!debug.contains("SYN-"));
1290 assert_eq!(client.url.expose(), url);
1291 }
1292
1293 #[rstest]
1294 fn test_auth_token_uses_secret_string_owner() {
1295 let client = AxMdWebSocketClient::new(
1296 "ws://localhost:9999/md/ws".to_string(),
1297 "initial-token".to_string(),
1298 30,
1299 TransportBackend::default(),
1300 None,
1301 );
1302
1303 client.set_auth_token("replacement-token".to_string());
1304
1305 let token = client.auth_token.lock();
1306 assert_eq!(
1307 token.as_ref().map(SecretString::expose_secret),
1308 Some("replacement-token")
1309 );
1310 }
1311
1312 #[tokio::test]
1313 async fn test_drop_aborts_handler_task() {
1314 let client = AxMdWebSocketClient::new(
1315 "ws://localhost:9999/md/ws".to_string(),
1316 "test_token".to_string(),
1317 30,
1318 TransportBackend::default(),
1319 None,
1320 );
1321 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1322 let handle = tokio::spawn(async move {
1323 started_tx.send(()).expect("started receiver");
1324 std::future::pending::<()>().await;
1325 });
1326 let abort_handle = handle.abort_handle();
1327 client.task_handle.insert(handle);
1328 started_rx.await.expect("handler task started");
1329
1330 drop(client);
1331
1332 tokio::time::timeout(Duration::from_secs(1), async {
1333 while !abort_handle.is_finished() {
1334 tokio::task::yield_now().await;
1335 }
1336 })
1337 .await
1338 .expect("handler task aborted");
1339 }
1340
1341 #[rstest]
1342 fn test_effective_subscription_empty_returns_none() {
1343 let sdt = SymbolDataTypes::default();
1344 assert_eq!(sdt.effective_subscription(), None);
1345 assert!(sdt.is_empty());
1346 }
1347
1348 #[rstest]
1349 fn test_effective_subscription_book_level_takes_precedence() {
1350 let sdt = SymbolDataTypes {
1351 book_level: Some(AxMarketDataLevel::Level2),
1352 quotes: true,
1353 ..Default::default()
1354 };
1355 assert_eq!(
1356 sdt.effective_subscription(),
1357 Some(AxMdSubscriptionSpec::new(
1358 AxMarketDataLevel::Level2,
1359 Some(false),
1360 Some(false),
1361 ))
1362 );
1363 assert!(!sdt.is_empty());
1364 }
1365
1366 #[rstest]
1367 #[case(
1368 true,
1369 false,
1370 false,
1371 false,
1372 AxMarketDataLevel::Level1,
1373 Some(false),
1374 Some(false)
1375 )]
1376 #[case(false, true, false, false, AxMarketDataLevel::Trades, None, None)]
1377 #[case(
1378 false,
1379 false,
1380 true,
1381 false,
1382 AxMarketDataLevel::Level1,
1383 Some(false),
1384 Some(true)
1385 )]
1386 #[case(
1387 false,
1388 false,
1389 false,
1390 true,
1391 AxMarketDataLevel::Level1,
1392 Some(false),
1393 Some(true)
1394 )]
1395 fn test_effective_subscription_for_single_data_type(
1396 #[case] quotes: bool,
1397 #[case] trades: bool,
1398 #[case] mark_prices: bool,
1399 #[case] instrument_status: bool,
1400 #[case] level: AxMarketDataLevel,
1401 #[case] include_trades: Option<bool>,
1402 #[case] include_ticker: Option<bool>,
1403 ) {
1404 let sdt = SymbolDataTypes {
1405 quotes,
1406 trades,
1407 mark_prices,
1408 instrument_status,
1409 book_level: None,
1410 };
1411 assert_eq!(
1412 sdt.effective_subscription(),
1413 Some(AxMdSubscriptionSpec::new(
1414 level,
1415 include_trades,
1416 include_ticker,
1417 ))
1418 );
1419 assert!(!sdt.is_empty());
1420 }
1421
1422 #[rstest]
1423 #[case(false)]
1424 #[case(true)]
1425 #[tokio::test]
1426 async fn test_unsubscribe_send_failure_restores_subscription(#[case] was_pending: bool) {
1427 let client = AxMdWebSocketClient::new(
1428 "ws://localhost:9999/md/ws".to_string(),
1429 "test_token".to_string(),
1430 30,
1431 TransportBackend::default(),
1432 None,
1433 );
1434 let symbol = "EURUSD-PERP";
1435 let spec = AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(false));
1436 let topic = spec.topic(symbol);
1437 client.subscriptions.mark_subscribe(&topic);
1438 if !was_pending {
1439 client.subscriptions.confirm_subscribe(&topic);
1440 }
1441
1442 let error = client.send_unsubscribe(symbol, spec).await.unwrap_err();
1443
1444 assert_eq!(error.to_string(), "Channel error: channel closed");
1445 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1446 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1447 assert_eq!(
1448 client.subscriptions.pending_subscribe_topics().len(),
1449 usize::from(was_pending)
1450 );
1451 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1452 }
1453
1454 #[rstest]
1455 #[case(false)]
1456 #[case(true)]
1457 #[tokio::test]
1458 async fn test_unsubscribe_candles_send_failure_restores_subscription(
1459 #[case] was_pending: bool,
1460 ) {
1461 let client = AxMdWebSocketClient::new(
1462 "ws://localhost:9999/md/ws".to_string(),
1463 "test_token".to_string(),
1464 30,
1465 TransportBackend::default(),
1466 None,
1467 );
1468 let symbol = "EURUSD-PERP";
1469 let width = AxCandleWidth::Minutes1;
1470 let topic = format!("candles:{symbol}:{width:?}");
1471 client.subscriptions.mark_subscribe(&topic);
1472 if !was_pending {
1473 client.subscriptions.confirm_subscribe(&topic);
1474 }
1475
1476 let error = client.unsubscribe_candles(symbol, width).await.unwrap_err();
1477
1478 assert_eq!(error.to_string(), "Channel error: channel closed");
1479 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1480 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1481 assert_eq!(
1482 client.subscriptions.pending_subscribe_topics().len(),
1483 usize::from(was_pending)
1484 );
1485 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1486 }
1487
1488 #[rstest]
1489 #[tokio::test]
1490 async fn test_unsubscribe_candles_skips_untracked_topic() {
1491 let client = AxMdWebSocketClient::new(
1492 "ws://localhost:9999/md/ws".to_string(),
1493 "test_token".to_string(),
1494 30,
1495 TransportBackend::default(),
1496 None,
1497 );
1498
1499 client
1500 .unsubscribe_candles("EURUSD-PERP", AxCandleWidth::Minutes1)
1501 .await
1502 .unwrap();
1503
1504 assert_eq!(client.subscription_count(), 0);
1505 assert!(client.subscriptions.all_topics().is_empty());
1506 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1507 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1508 }
1509
1510 const SYMBOL: &str = "EURUSD-PERP";
1511
1512 async fn book_client(
1514 current: SymbolDataTypes,
1515 ) -> (
1516 AxMdWebSocketClient,
1517 tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
1518 ) {
1519 let client = AxMdWebSocketClient::new(
1520 "ws://localhost:9999/md/ws".to_string(),
1521 "test_token".to_string(),
1522 30,
1523 TransportBackend::default(),
1524 None,
1525 );
1526 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1527 *client.cmd_tx.write().await = cmd_tx;
1528
1529 if let Some(spec) = current.effective_subscription() {
1530 let topic = spec.topic(SYMBOL);
1531 client.subscriptions.mark_subscribe(&topic);
1532 client.subscriptions.confirm_subscribe(&topic);
1533 client.symbol_data_types.insert(SYMBOL.to_string(), current);
1534 }
1535
1536 (client, cmd_rx)
1537 }
1538
1539 #[rstest]
1540 #[case::no_stream(SymbolDataTypes::default(), None, false)]
1541 #[case::quote_stream(
1542 SymbolDataTypes { quotes: true, ..Default::default() },
1543 Some("EURUSD-PERP:Level1:false:false"),
1544 false
1545 )]
1546 #[case::book_stream(
1547 SymbolDataTypes { book_level: Some(AxMarketDataLevel::Level2), ..Default::default() },
1548 None,
1549 true
1550 )]
1551 #[tokio::test]
1552 async fn test_subscribe_book_deltas_gated_writes_book_stream(
1553 #[case] current: SymbolDataTypes,
1554 #[case] unsubscribed: Option<&str>,
1555 #[case] replaced: bool,
1556 ) {
1557 let quotes = current.quotes;
1558 let (client, mut cmd_rx) = book_client(current).await;
1559 let gate = SnapshotGate::default();
1560 gate.lock().close();
1561
1562 let subscribe = client.subscribe_book_deltas_gated(
1563 Ustr::from(SYMBOL),
1564 AxMarketDataLevel::Level2,
1565 CancellationToken::new(),
1566 gate,
1567 );
1568
1569 let handler = async {
1570 let mut topics = Vec::new();
1571
1572 loop {
1573 match cmd_rx.recv().await.expect("command queued") {
1574 HandlerCommand::Unsubscribe { topic, .. } => topics.push(topic),
1575 HandlerCommand::WriteBook {
1576 spec,
1577 unsubscribe_request_id,
1578 completion,
1579 ..
1580 } => {
1581 completion.send(Ok(())).unwrap();
1582 return (topics, spec, unsubscribe_request_id.is_some());
1583 }
1584 other => panic!("unexpected command {other:?}"),
1585 }
1586 }
1587 };
1588
1589 let (result, (topics, spec, replaces)) = tokio::join!(subscribe, handler);
1590 let types = client
1591 .symbol_data_types
1592 .load()
1593 .get(SYMBOL)
1594 .cloned()
1595 .unwrap();
1596
1597 assert!(result.is_ok());
1598 assert_eq!(
1599 topics,
1600 unsubscribed
1601 .map(ToString::to_string)
1602 .into_iter()
1603 .collect::<Vec<_>>()
1604 );
1605 assert_eq!(
1606 spec,
1607 AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(false))
1608 );
1609 assert_eq!(replaces, replaced);
1610 assert_eq!(types.book_level, Some(AxMarketDataLevel::Level2));
1611 assert_eq!(types.quotes, quotes);
1612 assert_eq!(client.subscriptions.all_topics(), vec![spec.topic(SYMBOL)]);
1613 }
1614
1615 #[rstest]
1616 #[tokio::test]
1617 async fn test_book_subscription_write_failure_starts_recovery() {
1618 let (client, mut cmd_rx) = book_client(SymbolDataTypes::default()).await;
1619 let book_sync = BookSyncTracker::default();
1620 let tasks = TaskGroup::new();
1621 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
1622
1623 spawn_subscription_task(
1624 instrument_id,
1625 AxMarketDataLevel::Level2,
1626 SubscriptionOrder::default().next(),
1627 book_sync.clone(),
1628 client.clone(),
1629 Duration::from_secs(10),
1630 &tasks.spawner().unwrap(),
1631 );
1632
1633 let mut replaces = Vec::new();
1634
1635 for _ in 0..2 {
1636 let command = tokio::time::timeout(Duration::from_secs(5), cmd_rx.recv())
1637 .await
1638 .expect("book write queued")
1639 .expect("command channel open");
1640
1641 let HandlerCommand::WriteBook {
1642 unsubscribe_request_id,
1643 completion,
1644 ..
1645 } = command
1646 else {
1647 panic!("expected a book write, was {command:?}");
1648 };
1649
1650 replaces.push(unsubscribe_request_id.is_some());
1651
1652 let _ = completion.send(Err(AxWsClientError::Transport("write failed".to_string())));
1654 }
1655
1656 let owned = book_sync.claim_recovery(instrument_id).is_none();
1657 tasks.begin_shutdown();
1658
1659 assert_eq!(replaces, [false, true]);
1660 assert!(owned, "the running recovery owns the book");
1661 }
1662
1663 #[rstest]
1664 #[tokio::test]
1665 async fn test_resubscribe_book_gated_echoes_current_subscription() {
1666 let current = SymbolDataTypes {
1667 mark_prices: true,
1668 book_level: Some(AxMarketDataLevel::Level2),
1669 ..Default::default()
1670 };
1671
1672 let (client, mut cmd_rx) = book_client(current).await;
1673
1674 let resubscribe = client.resubscribe_book_gated(
1675 Ustr::from(SYMBOL),
1676 CancellationToken::new(),
1677 SnapshotGate::default(),
1678 );
1679
1680 let handler = async {
1681 match cmd_rx.recv().await.expect("command queued") {
1682 HandlerCommand::WriteBook {
1683 request_id,
1684 unsubscribe_request_id,
1685 symbol,
1686 spec,
1687 completion,
1688 ..
1689 } => {
1690 completion.send(Ok(())).unwrap();
1691 (request_id, unsubscribe_request_id, symbol, spec)
1692 }
1693 other => panic!("unexpected command {other:?}"),
1694 }
1695 };
1696
1697 let (result, (request_id, unsubscribe_request_id, symbol, spec)) =
1698 tokio::join!(resubscribe, handler);
1699
1700 assert!(result.is_ok());
1701 assert_eq!(request_id, 1);
1702 assert_eq!(unsubscribe_request_id, Some(2));
1703 assert_eq!(symbol, Ustr::from(SYMBOL));
1704 assert_eq!(
1705 spec,
1706 AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(true))
1707 );
1708 assert!(cmd_rx.try_recv().is_err());
1709 }
1710
1711 #[rstest]
1712 #[case::not_subscribed(
1713 SymbolDataTypes { quotes: true, ..Default::default() },
1714 false,
1715 "Client error: Book deltas for EURUSD-PERP are no longer subscribed"
1716 )]
1717 #[case::cancelled(
1718 SymbolDataTypes { book_level: Some(AxMarketDataLevel::Level2), ..Default::default() },
1719 true,
1720 "Client error: Book recovery canceled"
1721 )]
1722 #[tokio::test]
1723 async fn test_resubscribe_book_gated_refuses_without_write(
1724 #[case] current: SymbolDataTypes,
1725 #[case] cancelled: bool,
1726 #[case] expected: &str,
1727 ) {
1728 let (client, mut cmd_rx) = book_client(current).await;
1729 let cancel = CancellationToken::new();
1730
1731 if cancelled {
1732 cancel.cancel();
1733 }
1734
1735 let error = client
1736 .resubscribe_book_gated(Ustr::from(SYMBOL), cancel, SnapshotGate::default())
1737 .await
1738 .unwrap_err();
1739
1740 assert_eq!(error.to_string(), expected);
1741 assert!(cmd_rx.try_recv().is_err());
1742 }
1743}