Skip to main content

nautilus_architect_ax/websocket/data/
handler.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Market data WebSocket message handler for Ax.
17
18use std::{
19    collections::VecDeque,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24};
25
26use ahash::AHashMap;
27use nautilus_live::book::snapshot::SnapshotGate;
28use nautilus_network::websocket::{SubscriptionState, WebSocketClient};
29use tokio_tungstenite::tungstenite::Message;
30use tokio_util::sync::CancellationToken;
31use ustr::Ustr;
32
33use super::{AxMdSubscriptionSpec, AxWsClientError};
34use crate::{
35    common::enums::{AxCandleWidth, AxMdRequestType},
36    websocket::{
37        messages::{
38            AxDataWsMessage, AxMdMessage, AxMdSubscribe, AxMdSubscribeCandles, AxMdUnsubscribe,
39            AxMdUnsubscribeCandles,
40        },
41        parse::parse_md_message,
42    },
43};
44
45/// Commands sent from the outer client to the inner message handler.
46#[derive(Debug)]
47pub enum HandlerCommand {
48    /// Set the WebSocket client for this handler.
49    SetClient(WebSocketClient),
50    /// Disconnect the WebSocket connection.
51    Disconnect,
52    /// Replay all subscriptions after a reconnection.
53    ReplaySubscriptions,
54    /// Subscribe to market data for a symbol.
55    Subscribe {
56        /// Request ID for correlation.
57        request_id: i64,
58        /// Instrument symbol.
59        symbol: Ustr,
60        /// Market data subscription options.
61        spec: AxMdSubscriptionSpec,
62    },
63    /// Writes a book subscription on the current connection, opening `gate` once written.
64    ///
65    /// With an unsubscribe request ID, first unsubscribes the symbol so the venue restarts its
66    /// stream with a snapshot. That acknowledgement stays untracked, keeping the topic desired for
67    /// reconnect replay. The write always runs, since the subscription state already counts it;
68    /// `cancel` only keeps the gate closed.
69    WriteBook {
70        /// Request ID for correlation.
71        request_id: i64,
72        /// Request ID of the unsubscribe that replaces an open stream.
73        unsubscribe_request_id: Option<i64>,
74        /// Instrument symbol.
75        symbol: Ustr,
76        /// Market data subscription options.
77        spec: AxMdSubscriptionSpec,
78        /// Keeps the gate closed once cancelled.
79        cancel: CancellationToken,
80        /// Snapshot gate opened once the write completes.
81        gate: SnapshotGate,
82        /// Receives the write result.
83        completion: tokio::sync::oneshot::Sender<Result<(), AxWsClientError>>,
84    },
85    /// Unsubscribe from market data for a symbol.
86    Unsubscribe {
87        /// Request ID for correlation.
88        request_id: i64,
89        /// Instrument symbol.
90        symbol: Ustr,
91        /// Subscription topic for state tracking.
92        topic: String,
93    },
94    /// Subscribe to candle data for a symbol.
95    SubscribeCandles {
96        /// Request ID for correlation.
97        request_id: i64,
98        /// Instrument symbol.
99        symbol: Ustr,
100        /// Candle width/interval.
101        width: AxCandleWidth,
102    },
103    /// Unsubscribe from candle data for a symbol.
104    UnsubscribeCandles {
105        /// Request ID for correlation.
106        request_id: i64,
107        /// Instrument symbol.
108        symbol: Ustr,
109        /// Candle width/interval.
110        width: AxCandleWidth,
111        /// Subscription topic for state tracking.
112        topic: String,
113    },
114}
115
116/// Market data feed handler that processes WebSocket messages.
117///
118/// Runs in a dedicated Tokio task and owns the WebSocket client exclusively.
119/// Emits raw venue types for downstream consumers to parse.
120pub(crate) struct AxMdWsFeedHandler {
121    signal: Arc<AtomicBool>,
122    inner: Option<WebSocketClient>,
123    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
124    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
125    subscriptions: SubscriptionState,
126    message_queue: VecDeque<AxDataWsMessage>,
127    replay_request_id: i64,
128    needs_subscription_replay: bool,
129    pending_subscription_requests: AHashMap<i64, PendingSubscriptionRequest>,
130}
131
132impl AxMdWsFeedHandler {
133    /// Creates a new [`AxMdWsFeedHandler`] instance.
134    #[must_use]
135    pub(crate) fn new(
136        signal: Arc<AtomicBool>,
137        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
138        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
139        subscriptions: SubscriptionState,
140    ) -> Self {
141        Self {
142            signal,
143            inner: None,
144            cmd_rx,
145            raw_rx,
146            subscriptions,
147            message_queue: VecDeque::new(),
148            replay_request_id: -1,
149            needs_subscription_replay: false,
150            pending_subscription_requests: AHashMap::new(),
151        }
152    }
153
154    fn next_replay_request_id(&mut self) -> i64 {
155        self.replay_request_id -= 1;
156        self.replay_request_id
157    }
158
159    async fn replay_subscriptions(&mut self) {
160        self.pending_subscription_requests.clear();
161        let topics = self.subscriptions.reset_after_reconnect();
162        if topics.is_empty() {
163            log::debug!("No subscriptions to replay after reconnect");
164            return;
165        }
166
167        log::debug!("Replaying {} subscriptions after reconnect", topics.len());
168
169        for topic in topics {
170            // Topic format: "symbol:Level:trades:ticker" or "candles:symbol:Width"
171            if let Some(rest) = topic.strip_prefix("candles:") {
172                if let Some((symbol, width_str)) = rest.rsplit_once(':') {
173                    if let Some(width) = Self::parse_candle_width(width_str) {
174                        let request_id = self.next_replay_request_id();
175                        log::debug!(
176                            "Replaying candle subscription: symbol={symbol}, width={width:?}"
177                        );
178                        self.pending_subscription_requests.insert(
179                            request_id,
180                            PendingSubscriptionRequest::Subscribe(topic.clone()),
181                        );
182                        self.send_subscribe_candles(request_id, Ustr::from(symbol), width)
183                            .await;
184                    } else {
185                        log::warn!("Failed to parse candle width from topic: {topic}");
186                    }
187                } else {
188                    log::warn!("Invalid candle topic format: {topic}");
189                }
190            } else if let Some((symbol, spec)) = AxMdSubscriptionSpec::parse_topic(&topic) {
191                let request_id = self.next_replay_request_id();
192                log::debug!("Replaying market data subscription: symbol={symbol}, spec={spec:?}");
193                self.pending_subscription_requests
194                    .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
195                self.send_subscribe(request_id, symbol, spec).await;
196            } else {
197                log::warn!("Failed to parse market data subscription topic: {topic}");
198            }
199        }
200
201        log::debug!("Subscription replay completed");
202    }
203
204    fn parse_candle_width(s: &str) -> Option<AxCandleWidth> {
205        match s {
206            "Seconds1" => Some(AxCandleWidth::Seconds1),
207            "Seconds5" => Some(AxCandleWidth::Seconds5),
208            "Minutes1" => Some(AxCandleWidth::Minutes1),
209            "Minutes5" => Some(AxCandleWidth::Minutes5),
210            "Minutes15" => Some(AxCandleWidth::Minutes15),
211            "Hours1" => Some(AxCandleWidth::Hours1),
212            "Days1" => Some(AxCandleWidth::Days1),
213            _ => None,
214        }
215    }
216
217    /// Returns the next message from the handler.
218    ///
219    /// This method blocks until a message is available or the handler is stopped.
220    pub(crate) async fn next(&mut self) -> Option<AxDataWsMessage> {
221        loop {
222            if self.needs_subscription_replay && self.message_queue.is_empty() {
223                self.needs_subscription_replay = false;
224                self.replay_subscriptions().await;
225            }
226
227            if let Some(msg) = self.message_queue.pop_front() {
228                return Some(msg);
229            }
230
231            tokio::select! {
232                Some(cmd) = self.cmd_rx.recv() => {
233                    self.handle_command(cmd).await;
234                }
235
236                () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
237                    if self.signal.load(Ordering::Acquire) {
238                        log::debug!("Stop signal received during idle period");
239                        return None;
240                    }
241                }
242
243                msg = self.raw_rx.recv() => {
244                    let msg = match msg {
245                        Some(msg) => msg,
246                        None => {
247                            log::debug!("WebSocket stream closed");
248                            return None;
249                        }
250                    };
251
252                    if let Message::Ping(data) = &msg {
253                        log::trace!("Received ping frame with {} bytes", data.len());
254
255                        if let Some(client) = &self.inner
256                            && let Err(e) = client.send_pong(data.to_vec()).await
257                        {
258                            log::warn!("Failed to send pong frame: {e}");
259                        }
260                        continue;
261                    }
262
263                    if let Some(message) = self.parse_raw_message(msg) {
264                        self.message_queue.push_back(message);
265                    }
266
267                    if self.signal.load(Ordering::Acquire) {
268                        log::debug!("Stop signal received");
269                        return None;
270                    }
271                }
272            }
273        }
274    }
275
276    async fn handle_command(&mut self, cmd: HandlerCommand) {
277        match cmd {
278            HandlerCommand::SetClient(client) => {
279                log::debug!("WebSocketClient received by handler");
280                self.inner = Some(client);
281            }
282            HandlerCommand::Disconnect => {
283                log::debug!("Disconnect command received");
284
285                if let Some(inner) = self.inner.take() {
286                    inner.disconnect().await;
287                }
288            }
289            HandlerCommand::ReplaySubscriptions => {
290                log::debug!("ReplaySubscriptions command received");
291                self.replay_subscriptions().await;
292            }
293            HandlerCommand::Subscribe {
294                request_id,
295                symbol,
296                spec,
297            } => {
298                log::debug!(
299                    "Subscribe command received: request_id={request_id}, symbol={symbol}, spec={spec:?}"
300                );
301                let topic = spec.topic(symbol.as_str());
302                self.pending_subscription_requests
303                    .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
304                self.send_subscribe(request_id, symbol, spec).await;
305            }
306            HandlerCommand::WriteBook {
307                request_id,
308                unsubscribe_request_id,
309                symbol,
310                spec,
311                cancel,
312                gate,
313                completion,
314            } => {
315                log::debug!(
316                    "WriteBook command received: request_id={request_id}, symbol={symbol}, spec={spec:?}"
317                );
318                let result = self
319                    .write_book(request_id, unsubscribe_request_id, symbol, spec)
320                    .await;
321
322                if result.is_ok() && !cancel.is_cancelled() {
323                    gate.open();
324                }
325
326                let _ = completion.send(result);
327            }
328            HandlerCommand::Unsubscribe {
329                request_id,
330                symbol,
331                topic,
332            } => {
333                log::debug!(
334                    "Unsubscribe command received: request_id={request_id}, symbol={symbol}"
335                );
336                self.pending_subscription_requests
337                    .insert(request_id, PendingSubscriptionRequest::Unsubscribe(topic));
338                self.send_unsubscribe(request_id, symbol).await;
339            }
340            HandlerCommand::SubscribeCandles {
341                request_id,
342                symbol,
343                width,
344            } => {
345                log::debug!(
346                    "SubscribeCandles command received: request_id={request_id}, symbol={symbol}, width={width:?}"
347                );
348                let topic = format!("candles:{symbol}:{width:?}");
349                self.pending_subscription_requests
350                    .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
351                self.send_subscribe_candles(request_id, symbol, width).await;
352            }
353            HandlerCommand::UnsubscribeCandles {
354                request_id,
355                symbol,
356                width,
357                topic,
358            } => {
359                log::debug!(
360                    "UnsubscribeCandles command received: request_id={request_id}, symbol={symbol}, width={width:?}"
361                );
362                self.pending_subscription_requests
363                    .insert(request_id, PendingSubscriptionRequest::Unsubscribe(topic));
364                self.message_queue
365                    .push_back(AxDataWsMessage::CandleUnsubscribed { symbol, width });
366                self.send_unsubscribe_candles(request_id, symbol, width)
367                    .await;
368            }
369        }
370    }
371
372    async fn send_subscribe(&mut self, request_id: i64, symbol: Ustr, spec: AxMdSubscriptionSpec) {
373        let msg = AxMdSubscribe {
374            rid: request_id,
375            msg_type: AxMdRequestType::Subscribe,
376            symbol,
377            level: spec.level,
378            trades: spec.trades,
379            ticker: spec.ticker,
380        };
381
382        if let Err(e) = self.send_json(&msg).await {
383            self.pending_subscription_requests.remove(&request_id);
384            log::error!("Failed to send subscribe message: {e}");
385        }
386    }
387
388    async fn write_book(
389        &mut self,
390        request_id: i64,
391        unsubscribe_request_id: Option<i64>,
392        symbol: Ustr,
393        spec: AxMdSubscriptionSpec,
394    ) -> Result<(), AxWsClientError> {
395        let Some(client) = self.inner.as_ref() else {
396            return Err(AxWsClientError::Transport(
397                "No WebSocket client available".to_string(),
398            ));
399        };
400
401        let epoch = client.connection_epoch();
402        let topic = spec.topic(symbol.as_str());
403
404        if let Some(rid) = unsubscribe_request_id {
405            // Keeps the topic desired for reconnect replay, so its subscribe ack confirms it again
406            self.subscriptions.mark_failure(&topic);
407
408            let msg = AxMdUnsubscribe {
409                rid,
410                msg_type: AxMdRequestType::Unsubscribe,
411                symbol,
412            };
413
414            send_json_on_connection(client, &msg, epoch).await?;
415        }
416
417        let msg = AxMdSubscribe {
418            rid: request_id,
419            msg_type: AxMdRequestType::Subscribe,
420            symbol,
421            level: spec.level,
422            trades: spec.trades,
423            ticker: spec.ticker,
424        };
425
426        self.pending_subscription_requests
427            .insert(request_id, PendingSubscriptionRequest::Subscribe(topic));
428
429        if let Err(e) = send_json_on_connection(client, &msg, epoch).await {
430            self.pending_subscription_requests.remove(&request_id);
431            return Err(e);
432        }
433
434        Ok(())
435    }
436
437    async fn send_unsubscribe(&mut self, request_id: i64, symbol: Ustr) {
438        let msg = AxMdUnsubscribe {
439            rid: request_id,
440            msg_type: AxMdRequestType::Unsubscribe,
441            symbol,
442        };
443
444        if let Err(e) = self.send_json(&msg).await {
445            self.pending_subscription_requests.remove(&request_id);
446            log::error!("Failed to send unsubscribe message: {e}");
447        }
448    }
449
450    async fn send_subscribe_candles(
451        &mut self,
452        request_id: i64,
453        symbol: Ustr,
454        width: AxCandleWidth,
455    ) {
456        let msg = AxMdSubscribeCandles {
457            rid: request_id,
458            msg_type: AxMdRequestType::SubscribeCandles,
459            symbol,
460            width,
461        };
462
463        if let Err(e) = self.send_json(&msg).await {
464            self.pending_subscription_requests.remove(&request_id);
465            log::error!("Failed to send subscribe_candles message: {e}");
466        }
467    }
468
469    async fn send_unsubscribe_candles(
470        &mut self,
471        request_id: i64,
472        symbol: Ustr,
473        width: AxCandleWidth,
474    ) {
475        let msg = AxMdUnsubscribeCandles {
476            rid: request_id,
477            msg_type: AxMdRequestType::UnsubscribeCandles,
478            symbol,
479            width,
480        };
481
482        if let Err(e) = self.send_json(&msg).await {
483            self.pending_subscription_requests.remove(&request_id);
484            log::error!("Failed to send unsubscribe_candles message: {e}");
485        }
486    }
487
488    async fn send_json<T: serde::Serialize>(&self, msg: &T) -> Result<(), String> {
489        let Some(inner) = &self.inner else {
490            return Err("No WebSocket client available".to_string());
491        };
492
493        let payload = serde_json::to_string(msg).map_err(|e| e.to_string())?;
494        log::trace!("Sending WebSocket payload ({} bytes)", payload.len());
495
496        inner
497            .send_text(payload, None)
498            .await
499            .map_err(|e| e.to_string())
500    }
501
502    fn parse_raw_message(&mut self, msg: Message) -> Option<AxDataWsMessage> {
503        match msg {
504            Message::Text(text) => {
505                if text == nautilus_network::RECONNECTED {
506                    log::info!("Received WebSocket reconnected signal");
507                    self.needs_subscription_replay = true;
508                    return Some(AxDataWsMessage::Reconnected);
509                }
510
511                log::trace!("Raw websocket message: {text}");
512
513                match parse_md_message(&text) {
514                    Ok(message) => self.handle_message(message),
515                    Err(e) => {
516                        log::error!("Failed to parse WebSocket message: {e}: {text}");
517                        None
518                    }
519                }
520            }
521            Message::Binary(data) => {
522                log::debug!("Received binary message with {} bytes", data.len());
523                None
524            }
525            Message::Close(_) => {
526                log::debug!("Received close message, waiting for reconnection");
527                None
528            }
529            _ => None,
530        }
531    }
532
533    fn handle_message(&mut self, message: AxMdMessage) -> Option<AxDataWsMessage> {
534        match &message {
535            AxMdMessage::Error(error) => {
536                let is_benign = error.message.contains("already subscribed")
537                    || error.message.contains("not subscribed");
538
539                if let Some(rid) = error.request_id
540                    && let Some(request) = self.pending_subscription_requests.remove(&rid)
541                {
542                    match request {
543                        PendingSubscriptionRequest::Subscribe(topic) => {
544                            self.subscriptions.mark_failure(&topic);
545                        }
546                        PendingSubscriptionRequest::Unsubscribe(topic) => {
547                            self.subscriptions.confirm_unsubscribe(&topic);
548                        }
549                    }
550                }
551
552                if is_benign {
553                    log::warn!("Subscription state: {}", error.message);
554                } else {
555                    log::error!("Received error from exchange: {}", error.message);
556                }
557            }
558            AxMdMessage::SubscriptionResponse(response) => {
559                let is_subscribe = response.result.subscribed.is_some()
560                    || response.result.subscribed_candle.is_some();
561                let is_unsubscribe = response.result.unsubscribed.is_some()
562                    || response.result.unsubscribed_candle.is_some();
563
564                if let Some(request) = self.pending_subscription_requests.remove(&response.rid) {
565                    match request {
566                        PendingSubscriptionRequest::Subscribe(topic) if is_subscribe => {
567                            self.subscriptions.confirm_subscribe(&topic);
568                        }
569                        PendingSubscriptionRequest::Unsubscribe(topic) if is_unsubscribe => {
570                            self.subscriptions.confirm_unsubscribe(&topic);
571                        }
572                        request => {
573                            log::warn!(
574                                "Unexpected subscription response for request: {request:?}, \
575                                 response={response:?}"
576                            );
577                        }
578                    }
579                }
580
581                if let Some(symbol) = &response.result.subscribed {
582                    log::debug!("Subscription confirmed for symbol: {symbol}");
583                } else if let Some(candle) = &response.result.subscribed_candle {
584                    log::debug!("Candle subscription confirmed: {candle}");
585                } else if let Some(symbol) = &response.result.unsubscribed {
586                    log::debug!("Unsubscription confirmed for symbol: {symbol}");
587                } else if let Some(candle) = &response.result.unsubscribed_candle {
588                    log::debug!("Candle unsubscription confirmed: {candle}");
589                }
590                return None;
591            }
592            _ => {}
593        }
594
595        Some(AxDataWsMessage::MdMessage(message))
596    }
597}
598
599#[derive(Debug)]
600enum PendingSubscriptionRequest {
601    Subscribe(String),
602    Unsubscribe(String),
603}
604
605// Sends once on the connection identified by `epoch`, never replaying on a replacement
606async fn send_json_on_connection<T: serde::Serialize>(
607    client: &WebSocketClient,
608    msg: &T,
609    epoch: u64,
610) -> Result<(), AxWsClientError> {
611    let payload =
612        serde_json::to_string(msg).map_err(|e| AxWsClientError::ClientError(e.to_string()))?;
613
614    client
615        .send_text_on_connection(payload, None, epoch)
616        .await
617        .map_err(|e| AxWsClientError::Transport(e.to_string()))
618}
619
620#[cfg(test)]
621mod tests {
622    use nautilus_network::websocket::SubscriptionState;
623    use rstest::rstest;
624
625    use super::*;
626    use crate::{
627        common::enums::AxMarketDataLevel,
628        websocket::messages::{AxMdSubscriptionResponse, AxMdSubscriptionResult, AxWsError},
629    };
630
631    const TOPIC: &str = "EURUSD-PERP:Level2:false:false";
632
633    #[rstest]
634    fn test_subscription_response_confirms_subscribe() {
635        let subscriptions = SubscriptionState::new(':');
636        subscriptions.mark_subscribe(TOPIC);
637        let mut handler = create_handler(subscriptions.clone());
638        handler
639            .pending_subscription_requests
640            .insert(1, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
641
642        handler.handle_message(AxMdMessage::SubscriptionResponse(
643            AxMdSubscriptionResponse {
644                rid: 1,
645                result: AxMdSubscriptionResult {
646                    subscribed: Some(Ustr::from("EURUSD-PERP")),
647                    subscribed_candle: None,
648                    unsubscribed: None,
649                    unsubscribed_candle: None,
650                },
651            },
652        ));
653
654        assert_eq!(subscriptions.len(), 1);
655        assert!(subscriptions.pending_subscribe_topics().is_empty());
656        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
657    }
658
659    #[rstest]
660    fn test_subscription_error_keeps_topic_pending_for_replay() {
661        let subscriptions = SubscriptionState::new(':');
662        subscriptions.mark_subscribe(TOPIC);
663        subscriptions.confirm_subscribe(TOPIC);
664        let mut handler = create_handler(subscriptions.clone());
665        handler
666            .pending_subscription_requests
667            .insert(2, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
668
669        handler.handle_message(AxMdMessage::Error(AxWsError {
670            code: Some("400".to_string()),
671            message: "subscription failed".to_string(),
672            request_id: Some(2),
673        }));
674
675        assert_eq!(subscriptions.len(), 0);
676        assert_eq!(
677            subscriptions.pending_subscribe_topics(),
678            vec![TOPIC.to_string()]
679        );
680        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
681    }
682
683    #[rstest]
684    fn test_already_subscribed_keeps_topic_pending_for_replay() {
685        let subscriptions = SubscriptionState::new(':');
686        subscriptions.mark_subscribe(TOPIC);
687        let mut handler = create_handler(subscriptions.clone());
688        handler
689            .pending_subscription_requests
690            .insert(3, PendingSubscriptionRequest::Subscribe(TOPIC.to_string()));
691
692        handler.handle_message(AxMdMessage::Error(AxWsError {
693            code: Some("400".to_string()),
694            message: "already subscribed".to_string(),
695            request_id: Some(3),
696        }));
697
698        assert_eq!(subscriptions.len(), 0);
699        assert_eq!(
700            subscriptions.pending_subscribe_topics(),
701            vec![TOPIC.to_string()]
702        );
703        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
704    }
705
706    #[rstest]
707    #[case("not subscribed")]
708    #[case("subscription failed")]
709    fn test_unsubscribe_error_confirms_removal(#[case] message: &str) {
710        let subscriptions = SubscriptionState::new(':');
711        subscriptions.mark_subscribe(TOPIC);
712        subscriptions.confirm_subscribe(TOPIC);
713        subscriptions.mark_unsubscribe(TOPIC);
714        let mut handler = create_handler(subscriptions.clone());
715        handler.pending_subscription_requests.insert(
716            4,
717            PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
718        );
719
720        handler.handle_message(AxMdMessage::Error(AxWsError {
721            code: Some("400".to_string()),
722            message: message.to_string(),
723            request_id: Some(4),
724        }));
725
726        assert_eq!(subscriptions.len(), 0);
727        assert!(subscriptions.pending_subscribe_topics().is_empty());
728        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
729    }
730
731    #[rstest]
732    fn test_subscription_response_confirms_unsubscribe() {
733        let subscriptions = SubscriptionState::new(':');
734        subscriptions.mark_subscribe(TOPIC);
735        subscriptions.confirm_subscribe(TOPIC);
736        subscriptions.mark_unsubscribe(TOPIC);
737        let mut handler = create_handler(subscriptions.clone());
738        handler.pending_subscription_requests.insert(
739            5,
740            PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
741        );
742
743        handler.handle_message(AxMdMessage::SubscriptionResponse(
744            AxMdSubscriptionResponse {
745                rid: 5,
746                result: AxMdSubscriptionResult {
747                    subscribed: None,
748                    subscribed_candle: None,
749                    unsubscribed: Some(Ustr::from("EURUSD-PERP")),
750                    unsubscribed_candle: None,
751                },
752            },
753        ));
754
755        assert_eq!(subscriptions.len(), 0);
756        assert!(subscriptions.pending_subscribe_topics().is_empty());
757        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
758    }
759
760    #[rstest]
761    #[tokio::test]
762    async fn test_replay_clears_stale_requests_and_pending_unsubscribes() {
763        let subscriptions = SubscriptionState::new(':');
764        subscriptions.mark_subscribe(TOPIC);
765        subscriptions.confirm_subscribe(TOPIC);
766        subscriptions.mark_unsubscribe(TOPIC);
767        let mut handler = create_handler(subscriptions.clone());
768        handler.pending_subscription_requests.insert(
769            6,
770            PendingSubscriptionRequest::Unsubscribe(TOPIC.to_string()),
771        );
772
773        handler.replay_subscriptions().await;
774
775        assert_eq!(subscriptions.len(), 0);
776        assert!(subscriptions.all_topics().is_empty());
777        assert!(subscriptions.pending_subscribe_topics().is_empty());
778        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
779        assert!(handler.pending_subscription_requests.is_empty());
780    }
781
782    #[rstest]
783    #[tokio::test]
784    async fn test_write_book_failure_keeps_gate_closed() {
785        let mut handler = create_handler(SubscriptionState::new(':'));
786        let gate = SnapshotGate::default();
787        gate.lock().close();
788        let (completion, receiver) = tokio::sync::oneshot::channel();
789
790        // No client is set, so the write fails before any send
791        handler
792            .handle_command(HandlerCommand::WriteBook {
793                request_id: 7,
794                unsubscribe_request_id: Some(8),
795                symbol: Ustr::from("EURUSD-PERP"),
796                spec: AxMdSubscriptionSpec::new(
797                    AxMarketDataLevel::Level2,
798                    Some(false),
799                    Some(false),
800                ),
801                cancel: CancellationToken::new(),
802                gate: gate.clone(),
803                completion,
804            })
805            .await;
806
807        let result = receiver.await.unwrap();
808
809        assert!(matches!(
810            result,
811            Err(AxWsClientError::Transport(message)) if message == "No WebSocket client available"
812        ));
813        assert!(gate.lock().is_closed());
814        assert!(handler.pending_subscription_requests.is_empty());
815    }
816
817    fn create_handler(subscriptions: SubscriptionState) -> AxMdWsFeedHandler {
818        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
819        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
820
821        AxMdWsFeedHandler::new(
822            Arc::new(AtomicBool::new(false)),
823            cmd_rx,
824            raw_rx,
825            subscriptions,
826        )
827    }
828}