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 let connection_mode = 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 cmd_tx
489 .send(HandlerCommand::SetClient(client))
490 .map_err(|e| AxWsClientError::ChannelError(e.to_string()))?;
491
492 if !self.subscriptions.all_topics().is_empty() {
493 cmd_tx
494 .send(HandlerCommand::ReplaySubscriptions)
495 .map_err(|e| AxWsClientError::ChannelError(e.to_string()))?;
496 }
497
498 {
499 let mut command_sender = self.cmd_tx.write().await;
500 *command_sender = cmd_tx.clone();
501 self.connection_mode.store(connection_mode);
502 }
503
504 let signal = Arc::clone(&self.signal);
505 let subscriptions = self.subscriptions.clone();
506
507 if let Err(e) = self.task_handle.spawn(async move {
508 let mut handler =
509 AxMdWsFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions.clone());
510
511 while let Some(msg) = handler.next().await {
512 if matches!(msg, AxDataWsMessage::Reconnected) {
513 log::info!("WebSocket reconnected, subscriptions will be replayed");
514 }
515
516 if out_tx.send(msg).is_err() {
517 log::debug!("Output channel closed");
518 break;
519 }
520 }
521
522 log::debug!("Handler loop exited");
523 }) {
524 self.out_rx = None;
525 return Err(AxWsClientError::Transport(format!(
526 "Failed to start WebSocket handler task: {e}"
527 )));
528 }
529
530 if let Some(control) = &self.socket_control {
531 control.register(move || reconnect_handle.request_reconnect());
532 }
533
534 Ok(())
535 }
536
537 pub async fn subscribe_book_deltas(
546 &self,
547 symbol: &str,
548 level: AxMarketDataLevel,
549 ) -> AxWsResult<()> {
550 let _guard = self.subscribe_lock.lock().await;
551
552 let current = self
553 .symbol_data_types
554 .load()
555 .get(symbol)
556 .cloned()
557 .unwrap_or_default();
558
559 if current.book_level == Some(level) {
560 log::debug!("Book deltas already subscribed for {symbol} at {level:?}, skipping");
561 return Ok(());
562 }
563
564 let old_spec = current.effective_subscription();
565 let mut next = current.clone();
566 next.book_level = Some(level);
567 let new_spec = next.effective_subscription();
568
569 self.update_data_subscription(symbol, old_spec, new_spec)
570 .await?;
571
572 self.symbol_data_types.rcu(|m| {
573 let entry = m.entry(symbol.to_string()).or_default();
574 entry.book_level = Some(level);
575 });
576
577 Ok(())
578 }
579
580 pub(crate) async fn subscribe_book_deltas_gated(
588 &self,
589 symbol: Ustr,
590 level: AxMarketDataLevel,
591 cancel: CancellationToken,
592 gate: SnapshotGate,
593 ) -> AxWsResult<()> {
594 let (completion, receiver) = tokio::sync::oneshot::channel();
595
596 {
597 let _guard = self.subscribe_lock.lock().await;
598
599 if cancel.is_cancelled() {
600 return Ok(());
601 }
602
603 let current = self
604 .symbol_data_types
605 .load()
606 .get(symbol.as_str())
607 .cloned()
608 .unwrap_or_default();
609 let old_spec = current.effective_subscription();
610 let spec = current.book_subscription(level);
611 let replace = old_spec == Some(spec);
612
613 if let Some(old) = old_spec.filter(|_| !replace) {
614 log::debug!("Resubscribing {symbol}: {old:?} -> {spec:?}");
615 self.send_unsubscribe(symbol.as_str(), old).await?;
616 }
617
618 let topic = spec.topic(symbol.as_str());
619
620 if !replace {
621 self.subscriptions.mark_subscribe(&topic);
622 }
623
624 let command = HandlerCommand::WriteBook {
625 request_id: self.next_request_id(),
626 unsubscribe_request_id: replace.then(|| self.next_request_id()),
627 symbol,
628 spec,
629 cancel: cancel.clone(),
630 gate,
631 completion,
632 };
633
634 if let Err(e) = self.send_cmd(command).await {
635 if !replace {
636 self.subscriptions.mark_unsubscribe(&topic);
637 }
638
639 return Err(e);
640 }
641
642 self.symbol_data_types.rcu(|m| {
643 m.entry(symbol.to_string()).or_default().book_level = Some(level);
644 });
645 }
646
647 tokio::select! {
648 biased;
649 () = cancel.cancelled() => Ok(()),
650 result = receiver => result.map_err(|e| AxWsClientError::Transport(e.to_string()))?,
652 }
653 }
654
655 pub(crate) async fn resubscribe_book_gated(
661 &self,
662 symbol: Ustr,
663 cancel: CancellationToken,
664 gate: SnapshotGate,
665 ) -> AxWsResult<()> {
666 let (completion, receiver) = tokio::sync::oneshot::channel();
667
668 {
669 let _guard = self.subscribe_lock.lock().await;
671
672 if cancel.is_cancelled() {
673 return Err(AxWsClientError::ClientError(
674 "Book recovery canceled".to_string(),
675 ));
676 }
677
678 let Some(spec) = self
679 .symbol_data_types
680 .load()
681 .get(symbol.as_str())
682 .and_then(|types| types.book_level.map(|level| types.book_subscription(level)))
683 else {
684 return Err(AxWsClientError::ClientError(format!(
685 "Book deltas for {symbol} are no longer subscribed"
686 )));
687 };
688
689 self.send_cmd(HandlerCommand::WriteBook {
690 request_id: self.next_request_id(),
691 unsubscribe_request_id: Some(self.next_request_id()),
692 symbol,
693 spec,
694 cancel,
695 gate,
696 completion,
697 })
698 .await?;
699 }
700
701 receiver
702 .await
703 .map_err(|e| AxWsClientError::Transport(e.to_string()))?
704 }
705
706 pub async fn subscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
715 let _guard = self.subscribe_lock.lock().await;
716
717 let current = self
718 .symbol_data_types
719 .load()
720 .get(symbol)
721 .cloned()
722 .unwrap_or_default();
723 let old_spec = current.effective_subscription();
724 let mut next = current.clone();
725 next.quotes = true;
726 let new_spec = next.effective_subscription();
727
728 self.update_data_subscription(symbol, old_spec, new_spec)
729 .await?;
730
731 self.symbol_data_types.rcu(|m| {
732 m.entry(symbol.to_string()).or_default().quotes = true;
733 });
734
735 Ok(())
736 }
737
738 pub async fn subscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
747 let _guard = self.subscribe_lock.lock().await;
748
749 let current = self
750 .symbol_data_types
751 .load()
752 .get(symbol)
753 .cloned()
754 .unwrap_or_default();
755 let old_spec = current.effective_subscription();
756 let mut next = current.clone();
757 next.trades = true;
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 m.entry(symbol.to_string()).or_default().trades = true;
765 });
766
767 Ok(())
768 }
769
770 pub async fn unsubscribe_book_deltas(&self, symbol: &str) -> AxWsResult<()> {
779 let _guard = self.subscribe_lock.lock().await;
780
781 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
782 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe book deltas");
783 return Ok(());
784 };
785 let old_spec = current.effective_subscription();
786 let mut next = current.clone();
787 next.book_level = None;
788 let new_spec = next.effective_subscription();
789
790 self.update_data_subscription(symbol, old_spec, new_spec)
791 .await?;
792
793 self.symbol_data_types.rcu(|m| {
794 if let Some(entry) = m.get_mut(symbol) {
795 entry.book_level = None;
796 if entry.is_empty() {
797 m.remove(symbol);
798 }
799 }
800 });
801
802 Ok(())
803 }
804
805 pub async fn unsubscribe_quotes(&self, symbol: &str) -> AxWsResult<()> {
814 let _guard = self.subscribe_lock.lock().await;
815
816 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
817 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe quotes");
818 return Ok(());
819 };
820 let old_spec = current.effective_subscription();
821 let mut next = current.clone();
822 next.quotes = false;
823 let new_spec = next.effective_subscription();
824
825 self.update_data_subscription(symbol, old_spec, new_spec)
826 .await?;
827
828 self.symbol_data_types.rcu(|m| {
829 if let Some(entry) = m.get_mut(symbol) {
830 entry.quotes = false;
831 if entry.is_empty() {
832 m.remove(symbol);
833 }
834 }
835 });
836
837 Ok(())
838 }
839
840 pub async fn unsubscribe_trades(&self, symbol: &str) -> AxWsResult<()> {
849 let _guard = self.subscribe_lock.lock().await;
850
851 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
852 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe trades");
853 return Ok(());
854 };
855 let old_spec = current.effective_subscription();
856 let mut next = current.clone();
857 next.trades = false;
858 let new_spec = next.effective_subscription();
859
860 self.update_data_subscription(symbol, old_spec, new_spec)
861 .await?;
862
863 self.symbol_data_types.rcu(|m| {
864 if let Some(entry) = m.get_mut(symbol) {
865 entry.trades = false;
866 if entry.is_empty() {
867 m.remove(symbol);
868 }
869 }
870 });
871
872 Ok(())
873 }
874
875 pub async fn subscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
884 let _guard = self.subscribe_lock.lock().await;
885
886 let current = self
887 .symbol_data_types
888 .load()
889 .get(symbol)
890 .cloned()
891 .unwrap_or_default();
892 let old_spec = current.effective_subscription();
893 let mut next = current.clone();
894 next.mark_prices = true;
895 let new_spec = next.effective_subscription();
896
897 self.update_data_subscription(symbol, old_spec, new_spec)
898 .await?;
899
900 self.symbol_data_types.rcu(|m| {
901 m.entry(symbol.to_string()).or_default().mark_prices = true;
902 });
903
904 Ok(())
905 }
906
907 pub async fn unsubscribe_mark_prices(&self, symbol: &str) -> AxWsResult<()> {
916 let _guard = self.subscribe_lock.lock().await;
917
918 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
919 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe mark prices");
920 return Ok(());
921 };
922 let old_spec = current.effective_subscription();
923 let mut next = current.clone();
924 next.mark_prices = false;
925 let new_spec = next.effective_subscription();
926
927 self.update_data_subscription(symbol, old_spec, new_spec)
928 .await?;
929
930 self.symbol_data_types.rcu(|m| {
931 if let Some(entry) = m.get_mut(symbol) {
932 entry.mark_prices = false;
933 if entry.is_empty() {
934 m.remove(symbol);
935 }
936 }
937 });
938
939 Ok(())
940 }
941
942 pub async fn subscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
951 let _guard = self.subscribe_lock.lock().await;
952
953 let current = self
954 .symbol_data_types
955 .load()
956 .get(symbol)
957 .cloned()
958 .unwrap_or_default();
959 let old_spec = current.effective_subscription();
960 let mut next = current.clone();
961 next.instrument_status = true;
962 let new_spec = next.effective_subscription();
963
964 self.update_data_subscription(symbol, old_spec, new_spec)
965 .await?;
966
967 self.symbol_data_types.rcu(|m| {
968 m.entry(symbol.to_string()).or_default().instrument_status = true;
969 });
970
971 Ok(())
972 }
973
974 pub async fn unsubscribe_instrument_status(&self, symbol: &str) -> AxWsResult<()> {
983 let _guard = self.subscribe_lock.lock().await;
984
985 let Some(current) = self.symbol_data_types.load().get(symbol).cloned() else {
986 log::debug!("Symbol {symbol} not subscribed, skipping unsubscribe instrument status");
987 return Ok(());
988 };
989 let old_spec = current.effective_subscription();
990 let mut next = current.clone();
991 next.instrument_status = false;
992 let new_spec = next.effective_subscription();
993
994 self.update_data_subscription(symbol, old_spec, new_spec)
995 .await?;
996
997 self.symbol_data_types.rcu(|m| {
998 if let Some(entry) = m.get_mut(symbol) {
999 entry.instrument_status = false;
1000 if entry.is_empty() {
1001 m.remove(symbol);
1002 }
1003 }
1004 });
1005
1006 self.status_invalidations.lock().insert(Ustr::from(symbol));
1007
1008 Ok(())
1009 }
1010
1011 async fn update_data_subscription(
1012 &self,
1013 symbol: &str,
1014 old_spec: Option<AxMdSubscriptionSpec>,
1015 new_spec: Option<AxMdSubscriptionSpec>,
1016 ) -> AxWsResult<()> {
1017 if old_spec == new_spec {
1018 return Ok(());
1019 }
1020
1021 match (old_spec, new_spec) {
1022 (None, Some(spec)) => {
1023 log::debug!("Subscribing {symbol} at {spec:?}");
1024 self.send_subscribe(symbol, spec).await
1025 }
1026 (Some(old), None) => {
1027 log::debug!("Unsubscribing {symbol} (no remaining data types)");
1028 self.send_unsubscribe(symbol, old).await
1029 }
1030 (Some(old), Some(new)) => {
1031 log::debug!("Resubscribing {symbol}: {old:?} -> {new:?}");
1032 self.send_unsubscribe(symbol, old).await?;
1033 if let Err(e) = self.send_subscribe(symbol, new).await {
1034 log::warn!("Resubscribe failed for {symbol} at {new:?}: {e}");
1035 if let Err(restore_err) = self.send_subscribe(symbol, old).await {
1036 log::error!(
1037 "Failed to restore {symbol} at {old:?}: {restore_err}, \
1038 reconnection required"
1039 );
1040 self.subscriptions.mark_subscribe(&old.topic(symbol));
1041 }
1042 return Err(e);
1043 }
1044 Ok(())
1045 }
1046 (None, None) => Ok(()),
1047 }
1048 }
1049
1050 async fn send_subscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
1051 let topic = spec.topic(symbol);
1052 let request_id = self.next_request_id();
1053
1054 self.subscriptions.mark_subscribe(&topic);
1055
1056 if let Err(e) = self
1057 .send_cmd(HandlerCommand::Subscribe {
1058 request_id,
1059 symbol: Ustr::from(symbol),
1060 spec,
1061 })
1062 .await
1063 {
1064 self.subscriptions.mark_unsubscribe(&topic);
1065 return Err(e);
1066 }
1067
1068 Ok(())
1069 }
1070
1071 async fn send_unsubscribe(&self, symbol: &str, spec: AxMdSubscriptionSpec) -> AxWsResult<()> {
1072 let request_id = self.next_request_id();
1073 let topic = spec.topic(symbol);
1074 let was_pending = self
1075 .subscriptions
1076 .pending_subscribe_topics()
1077 .contains(&topic);
1078
1079 self.subscriptions.mark_unsubscribe(&topic);
1080
1081 if let Err(e) = self
1082 .send_cmd(HandlerCommand::Unsubscribe {
1083 request_id,
1084 symbol: Ustr::from(symbol),
1085 topic: topic.clone(),
1086 })
1087 .await
1088 {
1089 self.restore_unsubscribe_state(&topic, was_pending);
1090 return Err(e);
1091 }
1092
1093 Ok(())
1094 }
1095
1096 pub async fn subscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
1104 let _guard = self.subscribe_lock.lock().await;
1105 let topic = format!("candles:{symbol}:{width:?}");
1106
1107 if self.is_subscribed_topic(&topic) {
1109 log::debug!("Already subscribed to {topic}, skipping");
1110 return Ok(());
1111 }
1112
1113 let request_id = self.next_request_id();
1114
1115 self.subscriptions.mark_subscribe(&topic);
1117
1118 if let Err(e) = self
1119 .send_cmd(HandlerCommand::SubscribeCandles {
1120 request_id,
1121 symbol: Ustr::from(symbol),
1122 width,
1123 })
1124 .await
1125 {
1126 self.subscriptions.mark_unsubscribe(&topic);
1128 return Err(e);
1129 }
1130
1131 Ok(())
1132 }
1133
1134 pub async fn unsubscribe_candles(&self, symbol: &str, width: AxCandleWidth) -> AxWsResult<()> {
1140 let _guard = self.subscribe_lock.lock().await;
1141 let request_id = self.next_request_id();
1142 let topic = format!("candles:{symbol}:{width:?}");
1143 let was_pending = self
1144 .subscriptions
1145 .pending_subscribe_topics()
1146 .contains(&topic);
1147
1148 if !self.is_subscribed_topic(&topic) {
1149 log::debug!("Not subscribed to {topic}, skipping unsubscribe");
1150 return Ok(());
1151 }
1152
1153 self.subscriptions.mark_unsubscribe(&topic);
1154
1155 if let Err(e) = self
1156 .send_cmd(HandlerCommand::UnsubscribeCandles {
1157 request_id,
1158 symbol: Ustr::from(symbol),
1159 width,
1160 topic: topic.clone(),
1161 })
1162 .await
1163 {
1164 self.restore_unsubscribe_state(&topic, was_pending);
1165 return Err(e);
1166 }
1167
1168 Ok(())
1169 }
1170
1171 fn restore_unsubscribe_state(&self, topic: &str, was_pending: bool) {
1172 self.subscriptions.confirm_unsubscribe(topic);
1173 self.subscriptions.mark_subscribe(topic);
1174 if !was_pending {
1175 self.subscriptions.confirm_subscribe(topic);
1176 }
1177 }
1178
1179 pub fn stream(&mut self) -> impl futures_util::Stream<Item = AxDataWsMessage> + 'static {
1185 let rx = self
1186 .out_rx
1187 .take()
1188 .expect("Stream receiver already taken or client not connected - stream() can only be called once");
1189 let mut rx = Arc::try_unwrap(rx).expect(
1190 "Cannot take ownership of stream - client was cloned and other references exist",
1191 );
1192 async_stream::stream! {
1193 while let Some(msg) = rx.recv().await {
1194 yield msg;
1195 }
1196 }
1197 }
1198
1199 pub(crate) fn begin_shutdown(&self) {
1200 self.cancellation_token.load().cancel();
1201 self.signal.store(true, Ordering::Release);
1202 }
1203
1204 pub async fn disconnect(&self) {
1206 log::debug!("Disconnecting WebSocket");
1207 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1208 }
1209
1210 pub async fn close(&mut self) -> anyhow::Result<()> {
1216 let connect_lock = Arc::clone(&self.connect_lock);
1217 let _guard = connect_lock.lock().await;
1218 log::debug!("Closing WebSocket client");
1219
1220 self.cancellation_token.load().cancel();
1222 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
1223 tokio::time::sleep(Duration::from_millis(50)).await;
1224 self.signal.store(true, Ordering::Release);
1225
1226 let outcome = self
1227 .task_handle
1228 .finish(Duration::from_secs(2), Duration::from_secs(2))
1229 .await;
1230
1231 *self.reconnect_headers.lock() = None;
1232
1233 if let Some(control) = &self.socket_control {
1234 control.deregister();
1235 }
1236
1237 match outcome {
1238 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
1239 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
1240 "Architect AX data WebSocket handler failed: {error}"
1241 )),
1242 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
1243 "Architect AX data WebSocket handler did not stop after abort"
1244 )),
1245 }
1246 }
1247
1248 async fn send_cmd(&self, cmd: HandlerCommand) -> AxWsResult<()> {
1249 let guard = self.cmd_tx.read().await;
1250 guard
1251 .send(cmd)
1252 .map_err(|e| AxWsClientError::ChannelError(e.to_string()))
1253 }
1254}
1255
1256impl Drop for AxMdWebSocketClient {
1257 fn drop(&mut self) {
1258 if Arc::strong_count(&self.task_handle) == 1 && !self.task_handle.is_empty() {
1259 self.cancellation_token.load().cancel();
1260 self.signal.store(true, Ordering::Release);
1261 self.task_handle.abort();
1262
1263 if let Some(control) = &self.socket_control {
1264 control.deregister();
1265 }
1266 }
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use futures_util::StreamExt;
1273 use nautilus_live::task::TaskGroup;
1274 use nautilus_model::identifiers::InstrumentId;
1275 use nautilus_testkit::connection::race_command_with_publication;
1276 use rstest::rstest;
1277
1278 use super::*;
1279 use crate::{
1280 book::{recovery::spawn_subscription_task, sync::BookSyncTracker},
1281 websocket::data::subscription::SubscriptionOrder,
1282 };
1283
1284 #[tokio::test]
1285 async fn command_during_sender_publication_reaches_initialized_handler() {
1286 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1287 let addr = listener.local_addr().unwrap();
1288 let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
1289
1290 let server = tokio::spawn(async move {
1291 let (socket, _) = listener.accept().await.unwrap();
1292 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1293 while let Some(Ok(message)) = socket.next().await {
1294 if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
1295 let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
1296 let _ = received_tx.send(payload);
1297 }
1298 }
1299 });
1300
1301 let url = format!("ws://{addr}");
1302 let mut ws_client =
1303 AxMdWebSocketClient::new(url, "startup-token", 30, TransportBackend::default(), None);
1304 let observer = ws_client.clone();
1305
1306 let command = HandlerCommand::Subscribe {
1307 request_id: 71,
1308 symbol: Ustr::from("BTCUSD"),
1309 spec: AxMdSubscriptionSpec::new(AxMarketDataLevel::Trades, None, None),
1310 };
1311
1312 race_command_with_publication(
1313 Arc::clone(&ws_client.cmd_tx),
1314 ws_client.connect(),
1315 move || command,
1316 || observer.is_active(),
1317 )
1318 .await
1319 .expect("connect failed");
1320 let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
1321 ws_client.close().await.expect("disconnect failed");
1322 server.abort();
1323
1324 let payload = received
1325 .expect("startup command must reach server")
1326 .unwrap();
1327 assert_eq!(
1328 payload,
1329 serde_json::json!({
1330 "rid": 71,
1331 "type": "subscribe",
1332 "symbol": "BTCUSD",
1333 "level": "TRADES"
1334 })
1335 );
1336 }
1337
1338 #[rstest]
1339 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
1340 #[case(
1341 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
1342 "wss://stream.example.com:9443"
1343 )]
1344 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
1345 let client = AxMdWebSocketClient::new(
1346 url.to_string(),
1347 "SYN-auth-token",
1348 30,
1349 TransportBackend::default(),
1350 None,
1351 );
1352 let debug = format!("{client:?}");
1353 assert!(debug.contains(&format!("url: {origin:?}")));
1354 assert!(!debug.contains("SYN-"));
1355 assert_eq!(client.url.expose(), url);
1356 }
1357
1358 #[rstest]
1359 fn test_auth_token_uses_secret_string_owner() {
1360 let client = AxMdWebSocketClient::new(
1361 "ws://localhost:9999/md/ws".to_string(),
1362 "initial-token".to_string(),
1363 30,
1364 TransportBackend::default(),
1365 None,
1366 );
1367
1368 client.set_auth_token("replacement-token".to_string());
1369
1370 let token = client.auth_token.lock();
1371 assert_eq!(
1372 token.as_ref().map(SecretString::expose_secret),
1373 Some("replacement-token")
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn test_drop_aborts_handler_task() {
1379 let client = AxMdWebSocketClient::new(
1380 "ws://localhost:9999/md/ws".to_string(),
1381 "test_token".to_string(),
1382 30,
1383 TransportBackend::default(),
1384 None,
1385 );
1386 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1387 let handle = tokio::spawn(async move {
1388 started_tx.send(()).expect("started receiver");
1389 std::future::pending::<()>().await;
1390 });
1391 let abort_handle = handle.abort_handle();
1392 client.task_handle.insert(handle);
1393 started_rx.await.expect("handler task started");
1394
1395 drop(client);
1396
1397 tokio::time::timeout(Duration::from_secs(1), async {
1398 while !abort_handle.is_finished() {
1399 tokio::task::yield_now().await;
1400 }
1401 })
1402 .await
1403 .expect("handler task aborted");
1404 }
1405
1406 #[rstest]
1407 fn test_effective_subscription_empty_returns_none() {
1408 let sdt = SymbolDataTypes::default();
1409 assert_eq!(sdt.effective_subscription(), None);
1410 assert!(sdt.is_empty());
1411 }
1412
1413 #[rstest]
1414 fn test_effective_subscription_book_level_takes_precedence() {
1415 let sdt = SymbolDataTypes {
1416 book_level: Some(AxMarketDataLevel::Level2),
1417 quotes: true,
1418 ..Default::default()
1419 };
1420 assert_eq!(
1421 sdt.effective_subscription(),
1422 Some(AxMdSubscriptionSpec::new(
1423 AxMarketDataLevel::Level2,
1424 Some(false),
1425 Some(false),
1426 ))
1427 );
1428 assert!(!sdt.is_empty());
1429 }
1430
1431 #[rstest]
1432 #[case(
1433 true,
1434 false,
1435 false,
1436 false,
1437 AxMarketDataLevel::Level1,
1438 Some(false),
1439 Some(false)
1440 )]
1441 #[case(false, true, false, false, AxMarketDataLevel::Trades, None, None)]
1442 #[case(
1443 false,
1444 false,
1445 true,
1446 false,
1447 AxMarketDataLevel::Level1,
1448 Some(false),
1449 Some(true)
1450 )]
1451 #[case(
1452 false,
1453 false,
1454 false,
1455 true,
1456 AxMarketDataLevel::Level1,
1457 Some(false),
1458 Some(true)
1459 )]
1460 fn test_effective_subscription_for_single_data_type(
1461 #[case] quotes: bool,
1462 #[case] trades: bool,
1463 #[case] mark_prices: bool,
1464 #[case] instrument_status: bool,
1465 #[case] level: AxMarketDataLevel,
1466 #[case] include_trades: Option<bool>,
1467 #[case] include_ticker: Option<bool>,
1468 ) {
1469 let sdt = SymbolDataTypes {
1470 quotes,
1471 trades,
1472 mark_prices,
1473 instrument_status,
1474 book_level: None,
1475 };
1476 assert_eq!(
1477 sdt.effective_subscription(),
1478 Some(AxMdSubscriptionSpec::new(
1479 level,
1480 include_trades,
1481 include_ticker,
1482 ))
1483 );
1484 assert!(!sdt.is_empty());
1485 }
1486
1487 #[rstest]
1488 #[case(false)]
1489 #[case(true)]
1490 #[tokio::test]
1491 async fn test_unsubscribe_send_failure_restores_subscription(#[case] was_pending: bool) {
1492 let client = AxMdWebSocketClient::new(
1493 "ws://localhost:9999/md/ws".to_string(),
1494 "test_token".to_string(),
1495 30,
1496 TransportBackend::default(),
1497 None,
1498 );
1499 let symbol = "EURUSD-PERP";
1500 let spec = AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(false));
1501 let topic = spec.topic(symbol);
1502 client.subscriptions.mark_subscribe(&topic);
1503 if !was_pending {
1504 client.subscriptions.confirm_subscribe(&topic);
1505 }
1506
1507 let error = client.send_unsubscribe(symbol, spec).await.unwrap_err();
1508
1509 assert_eq!(error.to_string(), "Channel error: channel closed");
1510 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1511 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1512 assert_eq!(
1513 client.subscriptions.pending_subscribe_topics().len(),
1514 usize::from(was_pending)
1515 );
1516 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1517 }
1518
1519 #[rstest]
1520 #[case(false)]
1521 #[case(true)]
1522 #[tokio::test]
1523 async fn test_unsubscribe_candles_send_failure_restores_subscription(
1524 #[case] was_pending: bool,
1525 ) {
1526 let client = AxMdWebSocketClient::new(
1527 "ws://localhost:9999/md/ws".to_string(),
1528 "test_token".to_string(),
1529 30,
1530 TransportBackend::default(),
1531 None,
1532 );
1533 let symbol = "EURUSD-PERP";
1534 let width = AxCandleWidth::Minutes1;
1535 let topic = format!("candles:{symbol}:{width:?}");
1536 client.subscriptions.mark_subscribe(&topic);
1537 if !was_pending {
1538 client.subscriptions.confirm_subscribe(&topic);
1539 }
1540
1541 let error = client.unsubscribe_candles(symbol, width).await.unwrap_err();
1542
1543 assert_eq!(error.to_string(), "Channel error: channel closed");
1544 assert_eq!(client.subscription_count(), usize::from(!was_pending));
1545 assert_eq!(client.subscriptions.all_topics(), vec![topic]);
1546 assert_eq!(
1547 client.subscriptions.pending_subscribe_topics().len(),
1548 usize::from(was_pending)
1549 );
1550 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1551 }
1552
1553 #[rstest]
1554 #[tokio::test]
1555 async fn test_unsubscribe_candles_skips_untracked_topic() {
1556 let client = AxMdWebSocketClient::new(
1557 "ws://localhost:9999/md/ws".to_string(),
1558 "test_token".to_string(),
1559 30,
1560 TransportBackend::default(),
1561 None,
1562 );
1563
1564 client
1565 .unsubscribe_candles("EURUSD-PERP", AxCandleWidth::Minutes1)
1566 .await
1567 .unwrap();
1568
1569 assert_eq!(client.subscription_count(), 0);
1570 assert!(client.subscriptions.all_topics().is_empty());
1571 assert!(client.subscriptions.pending_subscribe_topics().is_empty());
1572 assert!(client.subscriptions.pending_unsubscribe_topics().is_empty());
1573 }
1574
1575 const SYMBOL: &str = "EURUSD-PERP";
1576
1577 async fn book_client(
1579 current: SymbolDataTypes,
1580 ) -> (
1581 AxMdWebSocketClient,
1582 tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
1583 ) {
1584 let client = AxMdWebSocketClient::new(
1585 "ws://localhost:9999/md/ws".to_string(),
1586 "test_token".to_string(),
1587 30,
1588 TransportBackend::default(),
1589 None,
1590 );
1591 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1592 *client.cmd_tx.write().await = cmd_tx;
1593
1594 if let Some(spec) = current.effective_subscription() {
1595 let topic = spec.topic(SYMBOL);
1596 client.subscriptions.mark_subscribe(&topic);
1597 client.subscriptions.confirm_subscribe(&topic);
1598 client.symbol_data_types.insert(SYMBOL.to_string(), current);
1599 }
1600
1601 (client, cmd_rx)
1602 }
1603
1604 #[rstest]
1605 #[case::no_stream(SymbolDataTypes::default(), None, false)]
1606 #[case::quote_stream(
1607 SymbolDataTypes { quotes: true, ..Default::default() },
1608 Some("EURUSD-PERP:Level1:false:false"),
1609 false
1610 )]
1611 #[case::book_stream(
1612 SymbolDataTypes { book_level: Some(AxMarketDataLevel::Level2), ..Default::default() },
1613 None,
1614 true
1615 )]
1616 #[tokio::test]
1617 async fn test_subscribe_book_deltas_gated_writes_book_stream(
1618 #[case] current: SymbolDataTypes,
1619 #[case] unsubscribed: Option<&str>,
1620 #[case] replaced: bool,
1621 ) {
1622 let quotes = current.quotes;
1623 let (client, mut cmd_rx) = book_client(current).await;
1624 let gate = SnapshotGate::default();
1625 gate.lock().close();
1626
1627 let subscribe = client.subscribe_book_deltas_gated(
1628 Ustr::from(SYMBOL),
1629 AxMarketDataLevel::Level2,
1630 CancellationToken::new(),
1631 gate,
1632 );
1633
1634 let handler = async {
1635 let mut topics = Vec::new();
1636
1637 loop {
1638 match cmd_rx.recv().await.expect("command queued") {
1639 HandlerCommand::Unsubscribe { topic, .. } => topics.push(topic),
1640 HandlerCommand::WriteBook {
1641 spec,
1642 unsubscribe_request_id,
1643 completion,
1644 ..
1645 } => {
1646 completion.send(Ok(())).unwrap();
1647 return (topics, spec, unsubscribe_request_id.is_some());
1648 }
1649 other => panic!("unexpected command {other:?}"),
1650 }
1651 }
1652 };
1653
1654 let (result, (topics, spec, replaces)) = tokio::join!(subscribe, handler);
1655 let types = client
1656 .symbol_data_types
1657 .load()
1658 .get(SYMBOL)
1659 .cloned()
1660 .unwrap();
1661
1662 assert!(result.is_ok());
1663 assert_eq!(
1664 topics,
1665 unsubscribed
1666 .map(ToString::to_string)
1667 .into_iter()
1668 .collect::<Vec<_>>()
1669 );
1670 assert_eq!(
1671 spec,
1672 AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(false))
1673 );
1674 assert_eq!(replaces, replaced);
1675 assert_eq!(types.book_level, Some(AxMarketDataLevel::Level2));
1676 assert_eq!(types.quotes, quotes);
1677 assert_eq!(client.subscriptions.all_topics(), vec![spec.topic(SYMBOL)]);
1678 }
1679
1680 #[rstest]
1681 #[tokio::test]
1682 async fn test_book_subscription_write_failure_starts_recovery() {
1683 let (client, mut cmd_rx) = book_client(SymbolDataTypes::default()).await;
1684 let book_sync = BookSyncTracker::default();
1685 let tasks = TaskGroup::new();
1686 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
1687
1688 spawn_subscription_task(
1689 instrument_id,
1690 AxMarketDataLevel::Level2,
1691 SubscriptionOrder::default().next(),
1692 book_sync.clone(),
1693 client.clone(),
1694 Duration::from_secs(10),
1695 &tasks.spawner().unwrap(),
1696 );
1697
1698 let mut replaces = Vec::new();
1699
1700 for _ in 0..2 {
1701 let command = tokio::time::timeout(Duration::from_secs(5), cmd_rx.recv())
1702 .await
1703 .expect("book write queued")
1704 .expect("command channel open");
1705
1706 let HandlerCommand::WriteBook {
1707 unsubscribe_request_id,
1708 completion,
1709 ..
1710 } = command
1711 else {
1712 panic!("expected a book write, was {command:?}");
1713 };
1714
1715 replaces.push(unsubscribe_request_id.is_some());
1716
1717 let _ = completion.send(Err(AxWsClientError::Transport("write failed".to_string())));
1719 }
1720
1721 let owned = book_sync.claim_recovery(instrument_id).is_none();
1722 tasks.begin_shutdown();
1723
1724 assert_eq!(replaces, [false, true]);
1725 assert!(owned, "the running recovery owns the book");
1726 }
1727
1728 #[rstest]
1729 #[tokio::test]
1730 async fn test_resubscribe_book_gated_echoes_current_subscription() {
1731 let current = SymbolDataTypes {
1732 mark_prices: true,
1733 book_level: Some(AxMarketDataLevel::Level2),
1734 ..Default::default()
1735 };
1736
1737 let (client, mut cmd_rx) = book_client(current).await;
1738
1739 let resubscribe = client.resubscribe_book_gated(
1740 Ustr::from(SYMBOL),
1741 CancellationToken::new(),
1742 SnapshotGate::default(),
1743 );
1744
1745 let handler = async {
1746 match cmd_rx.recv().await.expect("command queued") {
1747 HandlerCommand::WriteBook {
1748 request_id,
1749 unsubscribe_request_id,
1750 symbol,
1751 spec,
1752 completion,
1753 ..
1754 } => {
1755 completion.send(Ok(())).unwrap();
1756 (request_id, unsubscribe_request_id, symbol, spec)
1757 }
1758 other => panic!("unexpected command {other:?}"),
1759 }
1760 };
1761
1762 let (result, (request_id, unsubscribe_request_id, symbol, spec)) =
1763 tokio::join!(resubscribe, handler);
1764
1765 assert!(result.is_ok());
1766 assert_eq!(request_id, 1);
1767 assert_eq!(unsubscribe_request_id, Some(2));
1768 assert_eq!(symbol, Ustr::from(SYMBOL));
1769 assert_eq!(
1770 spec,
1771 AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(true))
1772 );
1773 assert!(cmd_rx.try_recv().is_err());
1774 }
1775
1776 #[rstest]
1777 #[case::not_subscribed(
1778 SymbolDataTypes { quotes: true, ..Default::default() },
1779 false,
1780 "Client error: Book deltas for EURUSD-PERP are no longer subscribed"
1781 )]
1782 #[case::cancelled(
1783 SymbolDataTypes { book_level: Some(AxMarketDataLevel::Level2), ..Default::default() },
1784 true,
1785 "Client error: Book recovery canceled"
1786 )]
1787 #[tokio::test]
1788 async fn test_resubscribe_book_gated_refuses_without_write(
1789 #[case] current: SymbolDataTypes,
1790 #[case] cancelled: bool,
1791 #[case] expected: &str,
1792 ) {
1793 let (client, mut cmd_rx) = book_client(current).await;
1794 let cancel = CancellationToken::new();
1795
1796 if cancelled {
1797 cancel.cancel();
1798 }
1799
1800 let error = client
1801 .resubscribe_book_gated(Ustr::from(SYMBOL), cancel, SnapshotGate::default())
1802 .await
1803 .unwrap_err();
1804
1805 assert_eq!(error.to_string(), expected);
1806 assert!(cmd_rx.try_recv().is_err());
1807 }
1808}