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 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/// Commands sent from the outer client to the inner message handler.
49#[derive(Debug)]
50pub enum HandlerCommand {
51    /// Set the WebSocket client for this handler.
52    SetClient(WebSocketClient),
53    /// Disconnect the WebSocket connection.
54    Disconnect,
55    /// Replay all subscriptions after a reconnection.
56    ReplaySubscriptions,
57    /// Subscribe to market data for a symbol.
58    Subscribe {
59        /// Request ID for correlation.
60        request_id: i64,
61        /// Instrument symbol.
62        symbol: Ustr,
63        /// Market data subscription options.
64        spec: AxMdSubscriptionSpec,
65    },
66    /// Writes a book subscription on the current connection, opening `gate` once written.
67    ///
68    /// With an unsubscribe request ID, first unsubscribes the symbol so the venue restarts its
69    /// stream with a snapshot. That acknowledgement stays untracked, keeping the topic desired for
70    /// reconnect replay. Cancellation skips unsent work and keeps the gate closed. A write already
71    /// admitted to the network can still complete, ahead of later subscription changes.
72    WriteBook {
73        /// Request ID for correlation.
74        request_id: i64,
75        /// Request ID of the unsubscribe that replaces an open stream.
76        unsubscribe_request_id: Option<i64>,
77        /// Instrument symbol.
78        symbol: Ustr,
79        /// Market data subscription options.
80        spec: AxMdSubscriptionSpec,
81        /// Cancels unsent work and keeps the gate closed.
82        cancel: CancellationToken,
83        /// Snapshot gate opened once the write completes.
84        gate: SnapshotGate,
85        /// Receives the write result.
86        completion: tokio::sync::oneshot::Sender<Result<(), AxWsClientError>>,
87    },
88    /// Unsubscribe from market data for a symbol.
89    Unsubscribe {
90        /// Request ID for correlation.
91        request_id: i64,
92        /// Instrument symbol.
93        symbol: Ustr,
94        /// Subscription topic for state tracking.
95        topic: String,
96    },
97    /// Subscribe to candle data for a symbol.
98    SubscribeCandles {
99        /// Request ID for correlation.
100        request_id: i64,
101        /// Instrument symbol.
102        symbol: Ustr,
103        /// Candle width/interval.
104        width: AxCandleWidth,
105    },
106    /// Unsubscribe from candle data for a symbol.
107    UnsubscribeCandles {
108        /// Request ID for correlation.
109        request_id: i64,
110        /// Instrument symbol.
111        symbol: Ustr,
112        /// Candle width/interval.
113        width: AxCandleWidth,
114        /// Subscription topic for state tracking.
115        topic: String,
116    },
117}
118
119/// Market data feed handler that processes WebSocket messages.
120///
121/// Runs in a dedicated Tokio task and owns the WebSocket client exclusively.
122/// Emits raw venue types for downstream consumers to parse.
123pub(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    /// Creates a new [`AxMdWsFeedHandler`] instance.
138    #[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            // Topic format: "symbol:Level:trades:ticker" or "candles:symbol:Width"
176            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    /// Returns the next message from the handler.
222    ///
223    /// This method blocks until a message is available or the handler is stopped.
224    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        // Keeps the topic desired for reconnect replay, so its subscribe ack confirms it again
663        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
694// Sends once on the connection identified by `epoch`, never replaying on a replacement
695async 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        // No client is set, so the write fails before any send
924        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}