1use std::{
19 collections::VecDeque,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24};
25
26use ahash::AHashMap;
27use futures_util::future::BoxFuture;
28use nautilus_live::book::snapshot::SnapshotGate;
29use nautilus_network::websocket::{SubscriptionState, WebSocketClient};
30use tokio_tungstenite::tungstenite::Message;
31use tokio_util::sync::CancellationToken;
32use ustr::Ustr;
33
34use super::{AxMdSubscriptionSpec, AxWsClientError};
35use crate::{
36 common::enums::{AxCandleWidth, AxMdRequestType},
37 websocket::{
38 messages::{
39 AxDataWsMessage, AxMdMessage, AxMdSubscribe, AxMdSubscribeCandles, AxMdUnsubscribe,
40 AxMdUnsubscribeCandles,
41 },
42 parse::parse_md_message,
43 },
44};
45
46const BOOK_WRITE_CANCELED: &str = "Book write canceled";
47
48#[derive(Debug)]
50pub enum HandlerCommand {
51 SetClient(WebSocketClient),
53 Disconnect,
55 ReplaySubscriptions,
57 Subscribe {
59 request_id: i64,
61 symbol: Ustr,
63 spec: AxMdSubscriptionSpec,
65 },
66 WriteBook {
73 request_id: i64,
75 unsubscribe_request_id: Option<i64>,
77 symbol: Ustr,
79 spec: AxMdSubscriptionSpec,
81 cancel: CancellationToken,
83 gate: SnapshotGate,
85 completion: tokio::sync::oneshot::Sender<Result<(), AxWsClientError>>,
87 },
88 Unsubscribe {
90 request_id: i64,
92 symbol: Ustr,
94 topic: String,
96 },
97 SubscribeCandles {
99 request_id: i64,
101 symbol: Ustr,
103 width: AxCandleWidth,
105 },
106 UnsubscribeCandles {
108 request_id: i64,
110 symbol: Ustr,
112 width: AxCandleWidth,
114 topic: String,
116 },
117}
118
119pub(crate) struct AxMdWsFeedHandler {
124 signal: Arc<AtomicBool>,
125 inner: Option<Arc<WebSocketClient>>,
126 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
127 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
128 subscriptions: SubscriptionState,
129 message_queue: VecDeque<AxDataWsMessage>,
130 replay_request_id: i64,
131 needs_subscription_replay: bool,
132 pending_subscription_requests: AHashMap<i64, PendingSubscriptionRequest>,
133 writes: VecDeque<PendingWrite>,
134}
135
136impl AxMdWsFeedHandler {
137 #[must_use]
139 pub(crate) fn new(
140 signal: Arc<AtomicBool>,
141 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
142 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
143 subscriptions: SubscriptionState,
144 ) -> Self {
145 Self {
146 signal,
147 inner: None,
148 cmd_rx,
149 raw_rx,
150 subscriptions,
151 message_queue: VecDeque::new(),
152 replay_request_id: -1,
153 needs_subscription_replay: false,
154 pending_subscription_requests: AHashMap::new(),
155 writes: VecDeque::new(),
156 }
157 }
158
159 fn next_replay_request_id(&mut self) -> i64 {
160 self.replay_request_id -= 1;
161 self.replay_request_id
162 }
163
164 fn replay_subscriptions(&mut self) {
165 self.pending_subscription_requests.clear();
166 let topics = self.subscriptions.reset_after_reconnect();
167 if topics.is_empty() {
168 log::debug!("No subscriptions to replay after reconnect");
169 return;
170 }
171
172 log::debug!("Replaying {} subscriptions after reconnect", topics.len());
173
174 for topic in topics {
175 if let Some(rest) = topic.strip_prefix("candles:") {
177 if let Some((symbol, width_str)) = rest.rsplit_once(':') {
178 if let Some(width) = Self::parse_candle_width(width_str) {
179 let request_id = self.next_replay_request_id();
180 log::debug!(
181 "Replaying candle subscription: symbol={symbol}, width={width:?}"
182 );
183 self.pending_subscription_requests.insert(
184 request_id,
185 PendingSubscriptionRequest::Subscribe(topic.clone()),
186 );
187 self.send_subscribe_candles(request_id, Ustr::from(symbol), width);
188 } else {
189 log::warn!("Failed to parse candle width from topic: {topic}");
190 }
191 } else {
192 log::warn!("Invalid candle topic format: {topic}");
193 }
194 } else if let Some((symbol, spec)) = AxMdSubscriptionSpec::parse_topic(&topic) {
195 let request_id = self.next_replay_request_id();
196 log::debug!("Replaying market data subscription: symbol={symbol}, spec={spec:?}");
197 self.pending_subscription_requests
198 .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
199 self.send_subscribe(request_id, symbol, spec);
200 } else {
201 log::warn!("Failed to parse market data subscription topic: {topic}");
202 }
203 }
204
205 log::debug!("Subscription replay completed");
206 }
207
208 fn parse_candle_width(s: &str) -> Option<AxCandleWidth> {
209 match s {
210 "Seconds1" => Some(AxCandleWidth::Seconds1),
211 "Seconds5" => Some(AxCandleWidth::Seconds5),
212 "Minutes1" => Some(AxCandleWidth::Minutes1),
213 "Minutes5" => Some(AxCandleWidth::Minutes5),
214 "Minutes15" => Some(AxCandleWidth::Minutes15),
215 "Hours1" => Some(AxCandleWidth::Hours1),
216 "Days1" => Some(AxCandleWidth::Days1),
217 _ => None,
218 }
219 }
220
221 pub(crate) async fn next(&mut self) -> Option<AxDataWsMessage> {
225 loop {
226 if self.needs_subscription_replay && self.message_queue.is_empty() {
227 self.needs_subscription_replay = false;
228 self.replay_subscriptions();
229 }
230
231 if let Some(msg) = self.message_queue.pop_front() {
232 return Some(msg);
233 }
234
235 tokio::select! {
236 biased;
237
238 failed = async {
239 match self.writes.front_mut() {
240 Some(write) => (&mut write.future).await,
241 None => std::future::pending().await,
242 }
243 } => {
244 let write = self.writes.pop_front();
245
246 if let Some(request_id) = failed {
247 self.pending_subscription_requests.remove(&request_id);
248 } else if let Some(write) = write
249 && let Some((_, Some(snapshot))) = write.book
250 {
251 self.message_queue.push_back(snapshot);
252 }
253 }
254
255 Some(cmd) = self.cmd_rx.recv() => {
256 self.handle_command(cmd).await;
257 }
258
259 () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
260 if self.signal.load(Ordering::Acquire) {
261 log::debug!("Stop signal received during idle period");
262 return None;
263 }
264 }
265
266 msg = self.raw_rx.recv() => {
267 let msg = match msg {
268 Some(msg) => msg,
269 None => {
270 log::debug!("WebSocket stream closed");
271 return None;
272 }
273 };
274
275 if let Message::Ping(data) = &msg {
276 log::trace!("Received ping frame with {} bytes", data.len());
277
278 if let Some(client) = &self.inner
279 && let Err(e) = client.send_pong(data.to_vec()).await
280 {
281 log::warn!("Failed to send pong frame: {e}");
282 }
283 continue;
284 }
285
286 if let Some(message) = self.parse_raw_message(msg) {
287 self.queue_message(message);
288 }
289
290 if self.signal.load(Ordering::Acquire) {
291 log::debug!("Stop signal received");
292 return None;
293 }
294 }
295 }
296 }
297 }
298
299 fn queue_message(&mut self, message: AxDataWsMessage) {
300 let symbol = match &message {
301 AxDataWsMessage::MdMessage(AxMdMessage::BookL2(book)) if book.st => Some(book.s),
302 AxDataWsMessage::MdMessage(AxMdMessage::BookL3(book)) if book.st => Some(book.s),
303 _ => None,
304 };
305
306 if let Some((pending_symbol, snapshot)) = self
307 .writes
308 .front_mut()
309 .and_then(|write| write.book.as_mut())
310 && symbol == Some(*pending_symbol)
311 {
312 *snapshot = Some(message);
313 } else {
314 self.message_queue.push_back(message);
315 }
316 }
317
318 async fn handle_command(&mut self, cmd: HandlerCommand) {
319 match cmd {
320 HandlerCommand::SetClient(client) => {
321 log::debug!("WebSocketClient received by handler");
322 self.inner = Some(Arc::new(client));
323 }
324 HandlerCommand::Disconnect => {
325 log::debug!("Disconnect command received");
326 self.writes.clear();
327
328 if let Some(inner) = self.inner.take() {
329 inner.disconnect().await;
330 }
331 }
332 HandlerCommand::ReplaySubscriptions => {
333 log::debug!("ReplaySubscriptions command received");
334 self.replay_subscriptions();
335 }
336 HandlerCommand::Subscribe {
337 request_id,
338 symbol,
339 spec,
340 } => {
341 log::debug!(
342 "Subscribe command received: request_id={request_id}, symbol={symbol}, spec={spec:?}"
343 );
344 let topic = spec.topic(symbol.as_str());
345 self.pending_subscription_requests
346 .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
347 self.send_subscribe(request_id, symbol, spec);
348 }
349 HandlerCommand::WriteBook {
350 request_id,
351 unsubscribe_request_id,
352 symbol,
353 spec,
354 cancel,
355 gate,
356 completion,
357 } => {
358 log::debug!(
359 "WriteBook command received: request_id={request_id}, symbol={symbol}, spec={spec:?}"
360 );
361
362 if cancel.is_cancelled() {
363 let _ = completion.send(Err(AxWsClientError::ClientError(
364 BOOK_WRITE_CANCELED.to_string(),
365 )));
366 return;
367 }
368
369 let Some(client) = self.inner.clone() else {
370 let _ = completion.send(Err(AxWsClientError::Transport(
371 "No WebSocket client available".to_string(),
372 )));
373 return;
374 };
375
376 let epoch = client.connection_epoch();
377 let topic = spec.topic(symbol.as_str());
378 let subscriptions = self.subscriptions.clone();
379 self.pending_subscription_requests
380 .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
381
382 let future = Box::pin(async move {
383 let result = write_book(
384 &client,
385 &subscriptions,
386 request_id,
387 unsubscribe_request_id,
388 symbol,
389 spec,
390 epoch,
391 &cancel,
392 )
393 .await;
394
395 let written = result.is_ok() && !cancel.is_cancelled();
396 if written {
397 gate.open();
398 }
399
400 let _ = completion.send(result);
401 (!written).then_some(request_id)
402 });
403
404 self.writes.push_back(PendingWrite {
405 future,
406 book: Some((symbol, None)),
407 });
408 }
409 HandlerCommand::Unsubscribe {
410 request_id,
411 symbol,
412 topic,
413 } => {
414 log::debug!(
415 "Unsubscribe command received: request_id={request_id}, symbol={symbol}"
416 );
417 self.pending_subscription_requests
418 .insert(request_id, PendingSubscriptionRequest::Unsubscribe(topic));
419 self.send_unsubscribe(request_id, symbol);
420 }
421 HandlerCommand::SubscribeCandles {
422 request_id,
423 symbol,
424 width,
425 } => {
426 log::debug!(
427 "SubscribeCandles command received: request_id={request_id}, symbol={symbol}, width={width:?}"
428 );
429 let topic = format!("candles:{symbol}:{width:?}");
430 self.pending_subscription_requests
431 .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
432 self.send_subscribe_candles(request_id, symbol, width);
433 }
434 HandlerCommand::UnsubscribeCandles {
435 request_id,
436 symbol,
437 width,
438 topic,
439 } => {
440 log::debug!(
441 "UnsubscribeCandles command received: request_id={request_id}, symbol={symbol}, width={width:?}"
442 );
443 self.pending_subscription_requests
444 .insert(request_id, PendingSubscriptionRequest::Unsubscribe(topic));
445 self.message_queue
446 .push_back(AxDataWsMessage::CandleUnsubscribed { symbol, width });
447 self.send_unsubscribe_candles(request_id, symbol, width);
448 }
449 }
450 }
451
452 fn send_subscribe(&mut self, request_id: i64, symbol: Ustr, spec: AxMdSubscriptionSpec) {
453 let msg = AxMdSubscribe {
454 rid: request_id,
455 msg_type: AxMdRequestType::Subscribe,
456 symbol,
457 level: spec.level,
458 trades: spec.trades,
459 ticker: spec.ticker,
460 };
461
462 if let Err(e) = self.send_json(request_id, &msg) {
463 self.pending_subscription_requests.remove(&request_id);
464 log::error!("Failed to send subscribe message: {e}");
465 }
466 }
467
468 fn send_unsubscribe(&mut self, request_id: i64, symbol: Ustr) {
469 let msg = AxMdUnsubscribe {
470 rid: request_id,
471 msg_type: AxMdRequestType::Unsubscribe,
472 symbol,
473 };
474
475 if let Err(e) = self.send_json(request_id, &msg) {
476 self.pending_subscription_requests.remove(&request_id);
477 log::error!("Failed to send unsubscribe message: {e}");
478 }
479 }
480
481 fn send_subscribe_candles(&mut self, request_id: i64, symbol: Ustr, width: AxCandleWidth) {
482 let msg = AxMdSubscribeCandles {
483 rid: request_id,
484 msg_type: AxMdRequestType::SubscribeCandles,
485 symbol,
486 width,
487 };
488
489 if let Err(e) = self.send_json(request_id, &msg) {
490 self.pending_subscription_requests.remove(&request_id);
491 log::error!("Failed to send subscribe_candles message: {e}");
492 }
493 }
494
495 fn send_unsubscribe_candles(&mut self, request_id: i64, symbol: Ustr, width: AxCandleWidth) {
496 let msg = AxMdUnsubscribeCandles {
497 rid: request_id,
498 msg_type: AxMdRequestType::UnsubscribeCandles,
499 symbol,
500 width,
501 };
502
503 if let Err(e) = self.send_json(request_id, &msg) {
504 self.pending_subscription_requests.remove(&request_id);
505 log::error!("Failed to send unsubscribe_candles message: {e}");
506 }
507 }
508
509 fn send_json<T: serde::Serialize>(&mut self, request_id: i64, msg: &T) -> Result<(), String> {
510 let Some(inner) = self.inner.clone() else {
511 return Err("No WebSocket client available".to_string());
512 };
513
514 let payload = serde_json::to_string(msg).map_err(|e| e.to_string())?;
515 log::trace!("Sending WebSocket payload ({} bytes)", payload.len());
516
517 let future = Box::pin(async move {
518 if let Err(e) = inner.send_text(payload, None).await {
519 log::error!("Failed to send subscription message: {e}");
520 return Some(request_id);
521 }
522
523 None
524 });
525
526 self.writes.push_back(PendingWrite { future, book: None });
527
528 Ok(())
529 }
530
531 fn parse_raw_message(&mut self, msg: Message) -> Option<AxDataWsMessage> {
532 match msg {
533 Message::Text(text) => {
534 if text == nautilus_network::RECONNECTED {
535 log::info!("Received WebSocket reconnected signal");
536 self.needs_subscription_replay = true;
537 return Some(AxDataWsMessage::Reconnected);
538 }
539
540 log::trace!("Raw websocket message: {text}");
541
542 match parse_md_message(&text) {
543 Ok(message) => self.handle_message(message),
544 Err(e) => {
545 log::error!("Failed to parse WebSocket message: {e}: {text}");
546 None
547 }
548 }
549 }
550 Message::Binary(data) => {
551 log::debug!("Received binary message with {} bytes", data.len());
552 None
553 }
554 Message::Close(_) => {
555 log::debug!("Received close message, waiting for reconnection");
556 None
557 }
558 _ => None,
559 }
560 }
561
562 fn handle_message(&mut self, message: AxMdMessage) -> Option<AxDataWsMessage> {
563 match &message {
564 AxMdMessage::Error(error) => {
565 let is_benign = error.message.contains("already subscribed")
566 || error.message.contains("not subscribed");
567
568 if let Some(rid) = error.request_id
569 && let Some(request) = self.pending_subscription_requests.remove(&rid)
570 {
571 match request {
572 PendingSubscriptionRequest::Subscribe(topic) => {
573 self.subscriptions.mark_failure(&topic);
574 }
575 PendingSubscriptionRequest::Unsubscribe(topic) => {
576 self.subscriptions.confirm_unsubscribe(&topic);
577 }
578 }
579 }
580
581 if is_benign {
582 log::warn!("Subscription state: {}", error.message);
583 } else {
584 log::error!("Received error from exchange: {}", error.message);
585 }
586 }
587 AxMdMessage::SubscriptionResponse(response) => {
588 let is_subscribe = response.result.subscribed.is_some()
589 || response.result.subscribed_candle.is_some();
590 let is_unsubscribe = response.result.unsubscribed.is_some()
591 || response.result.unsubscribed_candle.is_some();
592
593 if let Some(request) = self.pending_subscription_requests.remove(&response.rid) {
594 match request {
595 PendingSubscriptionRequest::Subscribe(topic) if is_subscribe => {
596 self.subscriptions.confirm_subscribe(&topic);
597 }
598 PendingSubscriptionRequest::Unsubscribe(topic) if is_unsubscribe => {
599 self.subscriptions.confirm_unsubscribe(&topic);
600 }
601 request => {
602 log::warn!(
603 "Unexpected subscription response for request: {request:?}, \
604 response={response:?}"
605 );
606 }
607 }
608 }
609
610 if let Some(symbol) = &response.result.subscribed {
611 log::debug!("Subscription confirmed for symbol: {symbol}");
612 } else if let Some(candle) = &response.result.subscribed_candle {
613 log::debug!("Candle subscription confirmed: {candle}");
614 } else if let Some(symbol) = &response.result.unsubscribed {
615 log::debug!("Unsubscription confirmed for symbol: {symbol}");
616 } else if let Some(candle) = &response.result.unsubscribed_candle {
617 log::debug!("Candle unsubscription confirmed: {candle}");
618 }
619 return None;
620 }
621 _ => {}
622 }
623
624 Some(AxDataWsMessage::MdMessage(message))
625 }
626}
627
628#[derive(Debug)]
629enum PendingSubscriptionRequest {
630 Subscribe(String),
631 Unsubscribe(String),
632}
633
634struct PendingWrite {
635 future: BoxFuture<'static, Option<i64>>,
636 book: Option<(Ustr, Option<AxDataWsMessage>)>,
637}
638
639#[expect(
640 clippy::too_many_arguments,
641 reason = "the queued write retains its subscription, connection, and cancellation ownership"
642)]
643async fn write_book(
644 client: &WebSocketClient,
645 subscriptions: &SubscriptionState,
646 request_id: i64,
647 unsubscribe_request_id: Option<i64>,
648 symbol: Ustr,
649 spec: AxMdSubscriptionSpec,
650 epoch: u64,
651 cancel: &CancellationToken,
652) -> Result<(), AxWsClientError> {
653 if cancel.is_cancelled() {
654 return Err(AxWsClientError::ClientError(
655 BOOK_WRITE_CANCELED.to_string(),
656 ));
657 }
658
659 if let Some(rid) = unsubscribe_request_id {
660 let topic = spec.topic(symbol.as_str());
661
662 subscriptions.mark_failure(&topic);
664
665 let msg = AxMdUnsubscribe {
666 rid,
667 msg_type: AxMdRequestType::Unsubscribe,
668 symbol,
669 };
670
671 send_json_on_connection(client, &msg, epoch, cancel).await?;
672 }
673
674 let msg = AxMdSubscribe {
675 rid: request_id,
676 msg_type: AxMdRequestType::Subscribe,
677 symbol,
678 level: spec.level,
679 trades: spec.trades,
680 ticker: spec.ticker,
681 };
682
683 send_json_on_connection(client, &msg, epoch, cancel).await?;
684
685 if client.connection_epoch() != epoch || !client.is_active() {
686 return Err(AxWsClientError::Transport(
687 "Book subscription connection changed".to_string(),
688 ));
689 }
690
691 Ok(())
692}
693
694async fn send_json_on_connection<T: serde::Serialize>(
696 client: &WebSocketClient,
697 msg: &T,
698 epoch: u64,
699 cancel: &CancellationToken,
700) -> Result<(), AxWsClientError> {
701 let payload =
702 serde_json::to_string(msg).map_err(|e| AxWsClientError::ClientError(e.to_string()))?;
703
704 tokio::select! {
705 biased;
706 () = cancel.cancelled() => {
707 Err(AxWsClientError::ClientError(BOOK_WRITE_CANCELED.to_string()))
708 }
709 result = client.send_text_on_connection(payload, None, epoch) => {
710 result.map_err(|e| AxWsClientError::Transport(e.to_string()))
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use std::time::Duration;
718
719 use futures_util::{StreamExt, poll};
720 use nautilus_network::{
721 mode::ConnectionMode,
722 websocket::{SubscriptionState, TransportBackend, WebSocketConfig},
723 };
724 use rstest::rstest;
725
726 use super::*;
727 use crate::{
728 common::enums::AxMarketDataLevel,
729 websocket::messages::{AxMdSubscriptionResponse, AxMdSubscriptionResult, AxWsError},
730 };
731
732 const TOPIC: &str = "EURUSD-PERP:Level2:false:false";
733
734 #[rstest]
735 fn test_subscription_response_confirms_subscribe() {
736 let subscriptions = SubscriptionState::new(':');
737 subscriptions.mark_subscribe(TOPIC);
738 let mut handler = create_handler(subscriptions.clone());
739 handler
740 .pending_subscription_requests
741 .insert(1, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
742
743 handler.handle_message(AxMdMessage::SubscriptionResponse(
744 AxMdSubscriptionResponse {
745 rid: 1,
746 result: AxMdSubscriptionResult {
747 subscribed: Some(Ustr::from("EURUSD-PERP")),
748 subscribed_candle: None,
749 unsubscribed: None,
750 unsubscribed_candle: None,
751 },
752 },
753 ));
754
755 assert_eq!(subscriptions.len(), 1);
756 assert!(subscriptions.pending_subscribe_topics().is_empty());
757 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
758 }
759
760 #[rstest]
761 fn test_subscription_error_keeps_topic_pending_for_replay() {
762 let subscriptions = SubscriptionState::new(':');
763 subscriptions.mark_subscribe(TOPIC);
764 subscriptions.confirm_subscribe(TOPIC);
765 let mut handler = create_handler(subscriptions.clone());
766 handler
767 .pending_subscription_requests
768 .insert(2, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
769
770 handler.handle_message(AxMdMessage::Error(AxWsError {
771 code: Some("400".to_string()),
772 message: "subscription failed".to_string(),
773 request_id: Some(2),
774 }));
775
776 assert_eq!(subscriptions.len(), 0);
777 assert_eq!(
778 subscriptions.pending_subscribe_topics(),
779 vec![TOPIC.to_string()]
780 );
781 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
782 }
783
784 #[rstest]
785 fn test_already_subscribed_keeps_topic_pending_for_replay() {
786 let subscriptions = SubscriptionState::new(':');
787 subscriptions.mark_subscribe(TOPIC);
788 let mut handler = create_handler(subscriptions.clone());
789 handler
790 .pending_subscription_requests
791 .insert(3, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
792
793 handler.handle_message(AxMdMessage::Error(AxWsError {
794 code: Some("400".to_string()),
795 message: "already subscribed".to_string(),
796 request_id: Some(3),
797 }));
798
799 assert_eq!(subscriptions.len(), 0);
800 assert_eq!(
801 subscriptions.pending_subscribe_topics(),
802 vec![TOPIC.to_string()]
803 );
804 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
805 }
806
807 #[rstest]
808 #[case("not subscribed")]
809 #[case("subscription failed")]
810 fn test_unsubscribe_error_confirms_removal(#[case] message: &str) {
811 let subscriptions = SubscriptionState::new(':');
812 subscriptions.mark_subscribe(TOPIC);
813 subscriptions.confirm_subscribe(TOPIC);
814 subscriptions.mark_unsubscribe(TOPIC);
815 let mut handler = create_handler(subscriptions.clone());
816 handler.pending_subscription_requests.insert(
817 4,
818 PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
819 );
820
821 handler.handle_message(AxMdMessage::Error(AxWsError {
822 code: Some("400".to_string()),
823 message: message.to_string(),
824 request_id: Some(4),
825 }));
826
827 assert_eq!(subscriptions.len(), 0);
828 assert!(subscriptions.pending_subscribe_topics().is_empty());
829 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
830 }
831
832 #[rstest]
833 fn test_subscription_response_confirms_unsubscribe() {
834 let subscriptions = SubscriptionState::new(':');
835 subscriptions.mark_subscribe(TOPIC);
836 subscriptions.confirm_subscribe(TOPIC);
837 subscriptions.mark_unsubscribe(TOPIC);
838 let mut handler = create_handler(subscriptions.clone());
839 handler.pending_subscription_requests.insert(
840 5,
841 PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
842 );
843
844 handler.handle_message(AxMdMessage::SubscriptionResponse(
845 AxMdSubscriptionResponse {
846 rid: 5,
847 result: AxMdSubscriptionResult {
848 subscribed: None,
849 subscribed_candle: None,
850 unsubscribed: Some(Ustr::from("EURUSD-PERP")),
851 unsubscribed_candle: None,
852 },
853 },
854 ));
855
856 assert_eq!(subscriptions.len(), 0);
857 assert!(subscriptions.pending_subscribe_topics().is_empty());
858 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
859 }
860
861 #[rstest]
862 #[tokio::test]
863 async fn test_replay_clears_stale_requests_and_pending_unsubscribes() {
864 let subscriptions = SubscriptionState::new(':');
865 subscriptions.mark_subscribe(TOPIC);
866 subscriptions.confirm_subscribe(TOPIC);
867 subscriptions.mark_unsubscribe(TOPIC);
868 let mut handler = create_handler(subscriptions.clone());
869 handler.pending_subscription_requests.insert(
870 6,
871 PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
872 );
873
874 handler.replay_subscriptions();
875
876 assert_eq!(subscriptions.len(), 0);
877 assert!(subscriptions.all_topics().is_empty());
878 assert!(subscriptions.pending_subscribe_topics().is_empty());
879 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
880 assert!(handler.pending_subscription_requests.is_empty());
881 }
882
883 #[rstest]
884 #[tokio::test]
885 async fn test_write_book_canceled_before_admission() {
886 let mut handler = create_handler(SubscriptionState::new(':'));
887 let cancel = CancellationToken::new();
888 cancel.cancel();
889 let gate = SnapshotGate::default();
890 gate.lock().close();
891 let (completion, receiver) = tokio::sync::oneshot::channel();
892
893 handler
894 .handle_command(HandlerCommand::WriteBook {
895 request_id: 9,
896 unsubscribe_request_id: Some(10),
897 symbol: Ustr::from("EURUSD-PERP"),
898 spec: AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(true)),
899 cancel,
900 gate: gate.clone(),
901 completion,
902 })
903 .await;
904
905 let result = receiver.await.unwrap();
906
907 assert!(matches!(
908 result,
909 Err(AxWsClientError::ClientError(message)) if message == "Book write canceled"
910 ));
911 assert!(gate.lock().is_closed());
912 assert!(handler.pending_subscription_requests.is_empty());
913 }
914
915 #[rstest]
916 #[tokio::test]
917 async fn test_write_book_failure_keeps_gate_closed() {
918 let mut handler = create_handler(SubscriptionState::new(':'));
919 let gate = SnapshotGate::default();
920 gate.lock().close();
921 let (completion, receiver) = tokio::sync::oneshot::channel();
922
923 handler
925 .handle_command(HandlerCommand::WriteBook {
926 request_id: 7,
927 unsubscribe_request_id: Some(8),
928 symbol: Ustr::from("EURUSD-PERP"),
929 spec: AxMdSubscriptionSpec::new(
930 AxMarketDataLevel::Level2,
931 Some(false),
932 Some(false),
933 ),
934 cancel: CancellationToken::new(),
935 gate: gate.clone(),
936 completion,
937 })
938 .await;
939
940 let result = receiver.await.unwrap();
941
942 assert!(matches!(
943 result,
944 Err(AxWsClientError::Transport(message)) if message == "No WebSocket client available"
945 ));
946 assert!(gate.lock().is_closed());
947 assert!(handler.pending_subscription_requests.is_empty());
948 }
949
950 #[rstest]
951 #[case(AxMarketDataLevel::Level2, include_str!("../../../test_data/ws_md_book_l2.json"), false)]
952 #[case(AxMarketDataLevel::Level3, include_str!("../../../test_data/ws_md_book_l3.json"), false)]
953 #[case(AxMarketDataLevel::Level2, include_str!("../../../test_data/ws_md_book_l2.json"), true)]
954 #[tokio::test]
955 async fn test_book_snapshot_waits_for_write_confirmation(
956 #[case] level: AxMarketDataLevel,
957 #[case] fixture: &str,
958 #[case] canceled: bool,
959 ) {
960 let mut connection = HandlerConnection::new().await;
961 let cancel = CancellationToken::new();
962 let (mut command, gate, result) = book_command(cancel.clone());
963
964 let HandlerCommand::WriteBook { spec, .. } = &mut command else {
965 unreachable!();
966 };
967
968 spec.level = level;
969 connection.handler.handle_command(command).await;
970 let (release, held) = tokio::sync::oneshot::channel();
971 let mut write = connection.handler.writes.pop_front().unwrap();
972 let future = write.future;
973 write.future = Box::pin(async move {
974 held.await.unwrap();
975 future.await
976 });
977
978 connection.handler.writes.push_back(write);
979
980 let mut expected: serde_json::Value = serde_json::from_str(fixture).unwrap();
981 connection
982 .frames
983 .send(Message::Text(expected.to_string().into()))
984 .unwrap();
985 connection.frame().await;
986 expected["ts"] = 1609459201_i64.into();
987 connection
988 .frames
989 .send(Message::Text(expected.to_string().into()))
990 .unwrap();
991 connection.frame().await;
992 assert!(gate.lock().is_closed());
993
994 let mut other = expected.clone();
995 other["s"] = "GBPUSD-PERP".into();
996 connection
997 .frames
998 .send(Message::Text(other.to_string().into()))
999 .unwrap();
1000 let delivered = tokio::time::timeout(Duration::from_secs(2), connection.handler.next())
1001 .await
1002 .unwrap()
1003 .unwrap();
1004 assert_eq!(
1005 book_value(delivered),
1006 book_value(AxDataWsMessage::MdMessage(
1007 parse_md_message(&other.to_string()).unwrap(),
1008 ))
1009 );
1010
1011 if canceled {
1012 cancel.cancel();
1013 }
1014
1015 release.send(()).unwrap();
1016
1017 if canceled {
1018 connection.frame().await;
1019 assert!(
1020 matches!(result.await.unwrap(), Err(AxWsClientError::ClientError(message)) if message == "Book write canceled")
1021 );
1022 assert!(gate.lock().is_closed());
1023 } else {
1024 let delivered = tokio::time::timeout(Duration::from_secs(2), connection.handler.next())
1025 .await
1026 .unwrap()
1027 .unwrap();
1028 assert_eq!(
1029 book_value(delivered),
1030 book_value(AxDataWsMessage::MdMessage(
1031 parse_md_message(&expected.to_string()).unwrap(),
1032 ))
1033 );
1034 assert!(result.await.unwrap().is_ok());
1035 assert!(!gate.lock().is_closed());
1036 }
1037
1038 connection.frame().await;
1039 }
1040
1041 fn book_value(message: AxDataWsMessage) -> serde_json::Value {
1042 match message {
1043 AxDataWsMessage::MdMessage(AxMdMessage::BookL2(book)) => {
1044 serde_json::to_value(book).unwrap()
1045 }
1046 AxDataWsMessage::MdMessage(AxMdMessage::BookL3(book)) => {
1047 serde_json::to_value(book).unwrap()
1048 }
1049 other => panic!("Expected book snapshot, received {other:?}"),
1050 }
1051 }
1052
1053 #[rstest]
1054 #[case(false)]
1055 #[case(true)]
1056 #[tokio::test]
1057 async fn test_canceled_queued_book_sends_nothing(#[case] cancel_before_queue: bool) {
1058 let mut connection = HandlerConnection::new().await;
1059 let cancel = CancellationToken::new();
1060 if cancel_before_queue {
1061 cancel.cancel();
1062 }
1063
1064 let (command, gate, result) = book_command(cancel.clone());
1065 connection.handler.handle_command(command).await;
1066 cancel.cancel();
1067 connection.frame().await;
1068
1069 assert!(matches!(
1070 tokio::time::timeout(Duration::from_secs(2), result).await.unwrap().unwrap(),
1071 Err(AxWsClientError::ClientError(message)) if message == "Book write canceled"
1072 ));
1073 assert_eq!(
1074 connection.written.try_recv(),
1075 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1076 );
1077 assert!(gate.lock().is_closed());
1078 assert!(connection.handler.pending_subscription_requests.is_empty());
1079 }
1080
1081 #[rstest]
1082 #[tokio::test]
1083 async fn test_held_book_write_allows_frames_and_turnover() {
1084 let mut connection = HandlerConnection::new().await;
1085 let client = connection.handler.inner.clone().unwrap();
1086 client
1087 .connection_mode_atomic()
1088 .store(ConnectionMode::Reconnect.as_u8(), Ordering::Release);
1089 let cancel = CancellationToken::new();
1090 let (command, gate, result) = book_command(cancel.clone());
1091 connection.handler.handle_command(command).await;
1092 assert!(poll!(Box::pin(connection.handler.next())).is_pending());
1093 connection.frame().await;
1094 assert_eq!(
1095 connection.written.try_recv(),
1096 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1097 );
1098 assert!(gate.lock().is_closed());
1099
1100 cancel.cancel();
1101 connection
1102 .commands
1103 .send(HandlerCommand::Unsubscribe {
1104 request_id: 13,
1105 symbol: Ustr::from("EURUSD-PERP"),
1106 topic: TOPIC.to_string(),
1107 })
1108 .unwrap();
1109
1110 connection
1111 .commands
1112 .send(HandlerCommand::Subscribe {
1113 request_id: 14,
1114 symbol: Ustr::from("EURUSD-PERP"),
1115 spec: AxMdSubscriptionSpec::new(AxMarketDataLevel::Level1, Some(true), Some(false)),
1116 })
1117 .unwrap();
1118
1119 connection.frame().await;
1120 assert!(matches!(
1121 tokio::time::timeout(Duration::from_secs(2), result).await.unwrap().unwrap(),
1122 Err(AxWsClientError::ClientError(message)) if message == "Book write canceled"
1123 ));
1124
1125 client
1126 .connection_mode_atomic()
1127 .store(ConnectionMode::Active.as_u8(), Ordering::Release);
1128
1129 let expected = [
1130 serde_json::to_value(AxMdUnsubscribe {
1131 rid: 13,
1132 msg_type: AxMdRequestType::Unsubscribe,
1133 symbol: Ustr::from("EURUSD-PERP"),
1134 })
1135 .unwrap(),
1136 serde_json::to_value(AxMdSubscribe {
1137 rid: 14,
1138 msg_type: AxMdRequestType::Subscribe,
1139 symbol: Ustr::from("EURUSD-PERP"),
1140 level: AxMarketDataLevel::Level1,
1141 trades: Some(true),
1142 ticker: Some(false),
1143 })
1144 .unwrap(),
1145 ];
1146
1147 let writes = async {
1148 let mut writes = Vec::new();
1149 for _ in 0..expected.len() {
1150 writes.push(connection.written.recv().await.unwrap());
1151 }
1152
1153 writes
1154 };
1155
1156 let writes = tokio::select! {
1157 frame = connection.handler.next() => panic!("Unexpected frame: {frame:?}"),
1158 writes = tokio::time::timeout(Duration::from_secs(2), writes) => writes.unwrap(),
1159 };
1160
1161 assert_eq!(writes, expected);
1162 assert!(gate.lock().is_closed());
1163 assert_eq!(
1164 connection.written.try_recv(),
1165 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1166 );
1167 }
1168
1169 #[rstest]
1170 #[tokio::test]
1171 async fn test_held_book_write_allows_shutdown() {
1172 let mut connection = HandlerConnection::new().await;
1173 connection
1174 .handler
1175 .inner
1176 .as_ref()
1177 .unwrap()
1178 .connection_mode_atomic()
1179 .store(ConnectionMode::Reconnect.as_u8(), Ordering::Release);
1180 let (command, gate, result) = book_command(CancellationToken::new());
1181 connection.handler.handle_command(command).await;
1182 assert!(poll!(Box::pin(connection.handler.next())).is_pending());
1183 connection
1184 .commands
1185 .send(HandlerCommand::Disconnect)
1186 .unwrap();
1187 connection.frame().await;
1188
1189 assert!(result.await.is_err());
1190 assert!(gate.lock().is_closed());
1191 assert_eq!(
1192 connection.written.try_recv(),
1193 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
1194 );
1195 }
1196
1197 #[rstest]
1198 #[tokio::test]
1199 async fn test_canceled_replacement_skips_subscribe_after_unsubscribe() {
1200 let mut connection = HandlerConnection::new().await;
1201 let cancel = CancellationToken::new();
1202 let (command, gate, result) = book_command(cancel.clone());
1203 connection.handler.handle_command(command).await;
1204 assert!(poll!(Box::pin(connection.handler.next())).is_pending());
1205 let unsubscribe = tokio::time::timeout(Duration::from_secs(2), connection.written.recv())
1206 .await
1207 .unwrap()
1208 .unwrap();
1209 cancel.cancel();
1210 connection.frame().await;
1211
1212 assert_eq!(
1213 unsubscribe,
1214 serde_json::to_value(AxMdUnsubscribe {
1215 rid: 12,
1216 msg_type: AxMdRequestType::Unsubscribe,
1217 symbol: Ustr::from("EURUSD-PERP"),
1218 })
1219 .unwrap()
1220 );
1221 assert!(matches!(
1222 tokio::time::timeout(Duration::from_secs(2), result).await.unwrap().unwrap(),
1223 Err(AxWsClientError::ClientError(message)) if message == "Book write canceled"
1224 ));
1225 assert!(gate.lock().is_closed());
1226 assert_eq!(
1227 connection.written.try_recv(),
1228 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1229 );
1230 }
1231
1232 #[rstest]
1233 #[tokio::test]
1234 async fn test_reconnected_book_write_keeps_gate_closed() {
1235 let mut connection = HandlerConnection::new().await;
1236 let client = connection.handler.inner.clone().unwrap();
1237 client
1238 .connection_mode_atomic()
1239 .store(ConnectionMode::Reconnect.as_u8(), Ordering::Release);
1240 let (command, gate, result) = book_command(CancellationToken::new());
1241 connection.handler.handle_command(command).await;
1242 assert!(poll!(Box::pin(connection.handler.next())).is_pending());
1243 client
1244 .connection_epoch_atomic()
1245 .fetch_add(1, Ordering::AcqRel);
1246 client
1247 .connection_mode_atomic()
1248 .store(ConnectionMode::Active.as_u8(), Ordering::Release);
1249 let result = tokio::select! {
1250 frame = connection.handler.next() => panic!("Unexpected frame: {frame:?}"),
1251 result = tokio::time::timeout(Duration::from_secs(2), result) => result.unwrap().unwrap(),
1252 };
1253
1254 assert!(matches!(
1255 result,
1256 Err(AxWsClientError::Transport(message)) if message == "send failed: connection changed before write"
1257 ));
1258 assert!(gate.lock().is_closed());
1259 assert_eq!(
1260 connection.written.try_recv(),
1261 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1262 );
1263 assert!(connection.handler.pending_subscription_requests.is_empty());
1264 }
1265
1266 #[rstest]
1267 #[tokio::test]
1268 async fn test_book_write_preserves_flags_and_opens_gate() {
1269 let mut connection = HandlerConnection::new().await;
1270 let (command, gate, result) = book_command(CancellationToken::new());
1271 connection.handler.handle_command(command).await;
1272 assert!(gate.lock().is_closed());
1273 assert_eq!(
1274 connection.written.try_recv(),
1275 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1276 );
1277 let result = tokio::select! {
1278 frame = connection.handler.next() => panic!("Unexpected frame: {frame:?}"),
1279 result = tokio::time::timeout(Duration::from_secs(2), result) => result.unwrap().unwrap(),
1280 };
1281 let first = connection.written.recv().await.unwrap();
1282 let second = connection.written.recv().await.unwrap();
1283
1284 assert!(result.is_ok());
1285 assert!(!gate.lock().is_closed());
1286 assert_eq!(
1287 first,
1288 serde_json::to_value(AxMdUnsubscribe {
1289 rid: 12,
1290 msg_type: AxMdRequestType::Unsubscribe,
1291 symbol: Ustr::from("EURUSD-PERP"),
1292 })
1293 .unwrap()
1294 );
1295 assert_eq!(
1296 second,
1297 serde_json::to_value(AxMdSubscribe {
1298 rid: 11,
1299 msg_type: AxMdRequestType::Subscribe,
1300 symbol: Ustr::from("EURUSD-PERP"),
1301 level: AxMarketDataLevel::Level2,
1302 trades: Some(false),
1303 ticker: Some(true),
1304 })
1305 .unwrap()
1306 );
1307 }
1308
1309 fn book_command(
1310 cancel: CancellationToken,
1311 ) -> (
1312 HandlerCommand,
1313 SnapshotGate,
1314 tokio::sync::oneshot::Receiver<Result<(), AxWsClientError>>,
1315 ) {
1316 let gate = SnapshotGate::default();
1317 gate.lock().close();
1318 let (completion, result) = tokio::sync::oneshot::channel();
1319
1320 let command = HandlerCommand::WriteBook {
1321 request_id: 11,
1322 unsubscribe_request_id: Some(12),
1323 symbol: Ustr::from("EURUSD-PERP"),
1324 spec: AxMdSubscriptionSpec::new(AxMarketDataLevel::Level2, Some(false), Some(true)),
1325 cancel,
1326 gate: gate.clone(),
1327 completion,
1328 };
1329
1330 (command, gate, result)
1331 }
1332
1333 struct HandlerConnection {
1334 handler: AxMdWsFeedHandler,
1335 commands: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1336 frames: tokio::sync::mpsc::UnboundedSender<Message>,
1337 written: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
1338 server: tokio::task::JoinHandle<()>,
1339 }
1340
1341 impl HandlerConnection {
1342 async fn new() -> Self {
1343 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1344 let url = format!("ws://{}", listener.local_addr().unwrap());
1345 let (sent, written) = tokio::sync::mpsc::unbounded_channel();
1346
1347 let server = tokio::spawn(async move {
1348 let (stream, _) = listener.accept().await.unwrap();
1349 let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
1350 while let Some(Ok(message)) = socket.next().await {
1351 if let Message::Text(text) = message {
1352 sent.send(serde_json::from_str(&text).unwrap()).unwrap();
1353 }
1354 }
1355 });
1356
1357 let config = WebSocketConfig::builder()
1358 .url(url)
1359 .backend(TransportBackend::Tungstenite)
1360 .build()
1361 .unwrap();
1362 let client = WebSocketClient::builder()
1363 .config(config)
1364 .message_handler(Arc::new(|_| {}))
1365 .connect()
1366 .await
1367 .unwrap();
1368 let (commands, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1369 let (frames, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1370
1371 let mut handler = AxMdWsFeedHandler::new(
1372 Arc::new(AtomicBool::new(false)),
1373 cmd_rx,
1374 raw_rx,
1375 SubscriptionState::new(':'),
1376 );
1377 handler
1378 .handle_command(HandlerCommand::SetClient(client))
1379 .await;
1380
1381 Self {
1382 handler,
1383 commands,
1384 frames,
1385 written,
1386 server,
1387 }
1388 }
1389
1390 async fn frame(&mut self) {
1391 let heartbeat = include_str!("../../../test_data/ws_md_heartbeat.json");
1392 self.frames.send(Message::Text(heartbeat.into())).unwrap();
1393 let frame = tokio::time::timeout(Duration::from_secs(2), self.handler.next())
1394 .await
1395 .expect("handler must process frames while a book write waits")
1396 .unwrap();
1397
1398 let AxDataWsMessage::MdMessage(AxMdMessage::Heartbeat(heartbeat)) = frame else {
1399 panic!("Expected heartbeat, received {frame:?}");
1400 };
1401
1402 assert_eq!(heartbeat.ts, 1609459200);
1403 assert_eq!(heartbeat.tn, 123456789);
1404 }
1405 }
1406
1407 impl Drop for HandlerConnection {
1408 fn drop(&mut self) {
1409 self.server.abort();
1410 }
1411 }
1412
1413 fn create_handler(subscriptions: SubscriptionState) -> AxMdWsFeedHandler {
1414 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1415 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1416
1417 AxMdWsFeedHandler::new(
1418 Arc::new(AtomicBool::new(false)),
1419 cmd_rx,
1420 raw_rx,
1421 subscriptions,
1422 )
1423 }
1424}