Skip to main content

nautilus_bybit/websocket/
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//! WebSocket message handler for Bybit.
17
18use std::sync::{
19    Arc,
20    atomic::{AtomicBool, Ordering},
21};
22
23use nautilus_network::{
24    retry::{RetryManager, create_websocket_retry_manager},
25    websocket::{AuthTracker, SubscriptionState, WebSocketClient},
26};
27use tokio_tungstenite::tungstenite::Message;
28
29use super::{
30    enums::BybitWsOperation,
31    error::{BybitWsError, create_bybit_timeout_error, should_retry_bybit_error},
32    messages::{
33        BybitWebSocketError, BybitWsFrame, BybitWsMessage, BybitWsResponse, BybitWsSubscriptionMsg,
34    },
35    parse::parse_bybit_ws_frame,
36};
37
38/// Commands sent from the outer client to the inner message handler.
39#[derive(Debug)]
40pub enum HandlerCommand {
41    SetClient(WebSocketClient),
42    Disconnect,
43    Authenticate { payload: String },
44    Subscribe { topics: Vec<String> },
45    Unsubscribe { topics: Vec<String> },
46    SendText { payload: String },
47}
48
49pub(super) struct BybitWsFeedHandler {
50    signal: Arc<AtomicBool>,
51    inner: Option<WebSocketClient>,
52    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
53    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
54    auth_tracker: AuthTracker,
55    subscriptions: SubscriptionState,
56    retry_manager: RetryManager<BybitWsError>,
57}
58
59impl BybitWsFeedHandler {
60    /// Creates a new [`BybitWsFeedHandler`] instance.
61    pub(super) fn new(
62        signal: Arc<AtomicBool>,
63        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
64        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
65        auth_tracker: AuthTracker,
66        subscriptions: SubscriptionState,
67    ) -> Self {
68        Self {
69            signal,
70            inner: None,
71            cmd_rx,
72            raw_rx,
73            auth_tracker,
74            subscriptions,
75            retry_manager: create_websocket_retry_manager(),
76        }
77    }
78
79    pub(super) fn is_stopped(&self) -> bool {
80        self.signal.load(Ordering::Relaxed)
81    }
82
83    /// Sends a WebSocket message with retry logic.
84    async fn send_with_retry(&self, payload: String) -> Result<(), BybitWsError> {
85        if let Some(client) = &self.inner {
86            self.retry_manager
87                .execute_with_retry(
88                    "websocket_send",
89                    || {
90                        let payload = payload.clone();
91                        async move {
92                            client
93                                .send_text(payload, None)
94                                .await
95                                .map_err(|e| BybitWsError::Transport(format!("Send failed: {e}")))
96                        }
97                    },
98                    should_retry_bybit_error,
99                    create_bybit_timeout_error,
100                )
101                .await
102        } else {
103            Err(BybitWsError::ClientError(
104                "No active WebSocket client".to_string(),
105            ))
106        }
107    }
108
109    pub(super) async fn next(&mut self) -> Option<BybitWsMessage> {
110        loop {
111            tokio::select! {
112                Some(cmd) = self.cmd_rx.recv() => {
113                    match cmd {
114                        HandlerCommand::SetClient(client) => {
115                            log::debug!("WebSocketClient received by handler");
116                            self.inner = Some(client);
117                        }
118                        HandlerCommand::Disconnect => {
119                            log::debug!("Disconnect command received");
120
121                            if let Some(client) = self.inner.take() {
122                                client.disconnect().await;
123                            }
124                        }
125                        HandlerCommand::Authenticate { payload } => {
126                            log::debug!("Authenticate command received");
127
128                            if let Err(e) = self.send_with_retry(payload).await {
129                                log::error!("Failed to send authentication after retries: {e}");
130                            }
131                        }
132                        HandlerCommand::Subscribe { topics } => {
133                            for topic in topics {
134                                log::debug!("Subscribing to topic: topic={topic}");
135                                if let Err(e) = self.send_with_retry(topic.clone()).await {
136                                    log::error!("Failed to send subscription after retries: topic={topic}, error={e}");
137                                }
138                            }
139                        }
140                        HandlerCommand::Unsubscribe { topics } => {
141                            for topic in topics {
142                                log::debug!("Unsubscribing from topic: topic={topic}");
143                                if let Err(e) = self.send_with_retry(topic.clone()).await {
144                                    log::error!("Failed to send unsubscription after retries: topic={topic}, error={e}");
145                                }
146                            }
147                        }
148                        HandlerCommand::SendText { payload } => {
149                            if let Err(e) = self.send_with_retry(payload).await {
150                                log::error!("Error sending text with retry: {e}");
151                            }
152                        }
153                    }
154                }
155
156                () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
157                    if self.signal.load(Ordering::Relaxed) {
158                        log::debug!("Stop signal received during idle period");
159                        return None;
160                    }
161                }
162
163                msg = self.raw_rx.recv() => {
164                    let msg = match msg {
165                        Some(msg) => msg,
166                        None => {
167                            log::debug!("WebSocket stream closed");
168                            return None;
169                        }
170                    };
171
172                    if let Message::Ping(data) = &msg {
173                        log::trace!("Received ping frame with {} bytes", data.len());
174
175                        if let Some(client) = &self.inner
176                            && let Err(e) = client.send_pong(data.to_vec()).await
177                        {
178                            log::warn!("Failed to send pong frame: error={e}");
179                        }
180                        continue;
181                    }
182
183                    let frame = match Self::parse_raw_frame(msg) {
184                        Some(frame) => frame,
185                        None => continue,
186                    };
187
188                    if self.signal.load(Ordering::Relaxed) {
189                        log::debug!("Stop signal received");
190                        return None;
191                    }
192
193                    match frame {
194                        BybitWsFrame::Subscription(ref sub_msg) => {
195                            self.handle_subscription_ack(sub_msg);
196                        }
197                        BybitWsFrame::Auth(auth_response) => {
198                            let is_success = auth_response.success.unwrap_or(false)
199                                || (auth_response.ret_code == Some(0));
200
201                            if is_success {
202                                self.auth_tracker.succeed();
203                                log::debug!("WebSocket authenticated");
204                            } else {
205                                let error_msg = auth_response
206                                    .ret_msg
207                                    .as_deref()
208                                    .unwrap_or("Authentication rejected");
209                                self.auth_tracker.fail(error_msg);
210                                log::error!("WebSocket authentication failed: error={error_msg}");
211                            }
212                            return Some(BybitWsMessage::Auth(auth_response));
213                        }
214                        BybitWsFrame::ErrorResponse(ref resp) => {
215                            // Failed subscription/unsubscription ACKs arrive as
216                            // ErrorResponse when success=false. Route them through
217                            // the subscription state machine when the op field is
218                            // present, otherwise forward as a generic error.
219                            if let Some(op) = &resp.op {
220                                if *op == BybitWsOperation::Subscribe
221                                    || *op == BybitWsOperation::Unsubscribe
222                                {
223                                    self.handle_subscription_error(resp);
224                                } else {
225                                    let error = BybitWebSocketError::from_response(resp);
226                                    return Some(BybitWsMessage::Error(error));
227                                }
228                            } else {
229                                let error = BybitWebSocketError::from_response(resp);
230                                return Some(BybitWsMessage::Error(error));
231                            }
232                        }
233                        BybitWsFrame::OrderResponse(resp) => {
234                            return Some(BybitWsMessage::OrderResponse(resp));
235                        }
236                        BybitWsFrame::Orderbook(msg) => {
237                            return Some(BybitWsMessage::Orderbook(msg));
238                        }
239                        BybitWsFrame::Trade(msg) => {
240                            return Some(BybitWsMessage::Trade(msg));
241                        }
242                        BybitWsFrame::Kline(msg) => {
243                            return Some(BybitWsMessage::Kline(msg));
244                        }
245                        BybitWsFrame::TickerLinear(msg) => {
246                            return Some(BybitWsMessage::TickerLinear(msg));
247                        }
248                        BybitWsFrame::TickerOption(msg) => {
249                            return Some(BybitWsMessage::TickerOption(msg));
250                        }
251                        BybitWsFrame::AccountOrder(msg) => {
252                            return Some(BybitWsMessage::AccountOrder(msg));
253                        }
254                        BybitWsFrame::AccountExecution(msg) => {
255                            return Some(BybitWsMessage::AccountExecution(msg));
256                        }
257                        BybitWsFrame::AccountExecutionFast(msg) => {
258                            return Some(BybitWsMessage::AccountExecutionFast(msg));
259                        }
260                        BybitWsFrame::AccountWallet(msg) => {
261                            return Some(BybitWsMessage::AccountWallet(msg));
262                        }
263                        BybitWsFrame::AccountPosition(msg) => {
264                            return Some(BybitWsMessage::AccountPosition(msg));
265                        }
266                        BybitWsFrame::Reconnected => {
267                            self.auth_tracker.invalidate();
268                            return Some(BybitWsMessage::Reconnected);
269                        }
270                        BybitWsFrame::Unknown(value) => {
271                            log::debug!("Unknown WebSocket frame: {value}");
272                        }
273                    }
274                }
275            }
276        }
277    }
278
279    fn handle_subscription_ack(&self, sub_msg: &BybitWsSubscriptionMsg) {
280        match sub_msg.op {
281            BybitWsOperation::Subscribe => {
282                if sub_msg.success {
283                    if let Some(topic) = &sub_msg.req_id {
284                        self.subscriptions.confirm_subscribe(topic);
285                        log::debug!("Subscription confirmed: topic={topic}");
286                    } else {
287                        // No req_id, fall back to confirming all pending
288                        for topic in self.subscriptions.pending_subscribe_topics() {
289                            self.subscriptions.confirm_subscribe(&topic);
290                            log::debug!("Subscription confirmed (bulk): topic={topic}");
291                        }
292                    }
293                } else if let Some(topic) = &sub_msg.req_id {
294                    self.subscriptions.mark_failure(topic);
295                    log::warn!(
296                        "Subscription failed: topic={topic}, error={:?}",
297                        sub_msg.ret_msg
298                    );
299                } else {
300                    for topic in self.subscriptions.pending_subscribe_topics() {
301                        self.subscriptions.mark_failure(&topic);
302                        log::warn!(
303                            "Subscription failed (bulk): topic={topic}, error={:?}",
304                            sub_msg.ret_msg
305                        );
306                    }
307                }
308            }
309            BybitWsOperation::Unsubscribe => {
310                if sub_msg.success {
311                    if let Some(topic) = &sub_msg.req_id {
312                        self.subscriptions.confirm_unsubscribe(topic);
313                        log::debug!("Unsubscription confirmed: topic={topic}");
314                    } else {
315                        for topic in self.subscriptions.pending_unsubscribe_topics() {
316                            self.subscriptions.confirm_unsubscribe(&topic);
317                            log::debug!("Unsubscription confirmed (bulk): topic={topic}");
318                        }
319                    }
320                } else {
321                    let topic_desc = sub_msg.req_id.as_deref().unwrap_or("unknown");
322                    log::warn!(
323                        "Unsubscription failed: topic={topic_desc}, error={:?}",
324                        sub_msg.ret_msg
325                    );
326                }
327            }
328            _ => {}
329        }
330    }
331
332    fn handle_subscription_error(&self, resp: &BybitWsResponse) {
333        let topic = resp.req_id.as_deref().unwrap_or("unknown");
334        let error_msg = resp.ret_msg.as_deref().unwrap_or("unknown error");
335
336        match resp.op {
337            Some(BybitWsOperation::Subscribe) => {
338                // Duplicate subscribe is harmless: the topic is active on the
339                // venue, so confirm it instead of looping retries every reconnect.
340                if is_already_subscribed_error(error_msg)
341                    && let Some(ref req_id) = resp.req_id
342                {
343                    self.subscriptions.confirm_subscribe(req_id);
344                    log::debug!("Subscription duplicate ignored: topic={topic}, error={error_msg}");
345                    return;
346                }
347
348                if let Some(ref req_id) = resp.req_id {
349                    self.subscriptions.mark_failure(req_id);
350                } else {
351                    for t in self.subscriptions.pending_subscribe_topics() {
352                        self.subscriptions.mark_failure(&t);
353                    }
354                }
355                log::warn!("Subscription error: topic={topic}, error={error_msg}");
356            }
357            Some(BybitWsOperation::Unsubscribe) => {
358                log::warn!("Unsubscription error: topic={topic}, error={error_msg}");
359            }
360            _ => {}
361        }
362    }
363
364    fn parse_raw_frame(msg: Message) -> Option<BybitWsFrame> {
365        match msg {
366            Message::Text(text) => {
367                if text == nautilus_network::RECONNECTED {
368                    log::debug!("Received WebSocket reconnected signal");
369                    return Some(BybitWsFrame::Reconnected);
370                }
371
372                if text.trim().eq_ignore_ascii_case("pong") {
373                    return None;
374                }
375
376                log::trace!("Raw websocket message: {text}");
377
378                let value: serde_json::Value = match serde_json::from_str(&text) {
379                    Ok(v) => v,
380                    Err(e) => {
381                        log::error!("Failed to parse WebSocket message: {e}: {text}");
382                        return None;
383                    }
384                };
385
386                if value
387                    .get("op")
388                    .and_then(serde_json::Value::as_str)
389                    .is_some_and(|op| op == BybitWsOperation::Pong.as_ref())
390                {
391                    return None;
392                }
393
394                Some(parse_bybit_ws_frame(value))
395            }
396            Message::Binary(msg) => {
397                log::debug!("Raw binary frame ({} bytes)", msg.len());
398                log::trace!("Raw binary: {msg:?}");
399                None
400            }
401            Message::Close(_) => {
402                log::debug!("Received close message, waiting for reconnection");
403                None
404            }
405            _ => None,
406        }
407    }
408}
409
410fn is_already_subscribed_error(error_msg: &str) -> bool {
411    error_msg
412        .to_ascii_lowercase()
413        .contains("already subscribed")
414}
415
416#[cfg(test)]
417mod tests {
418    use rstest::rstest;
419    use ustr::Ustr;
420
421    use super::*;
422    use crate::common::{consts::BYBIT_WS_TOPIC_DELIMITER, testing::load_test_json};
423
424    fn create_test_handler() -> BybitWsFeedHandler {
425        let signal = Arc::new(AtomicBool::new(false));
426        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
427        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
428        let auth_tracker = AuthTracker::new();
429        let subscriptions = SubscriptionState::new(BYBIT_WS_TOPIC_DELIMITER);
430
431        BybitWsFeedHandler::new(signal, cmd_rx, raw_rx, auth_tracker, subscriptions)
432    }
433
434    fn load_value(fixture: &str) -> serde_json::Value {
435        let json = load_test_json(fixture);
436        serde_json::from_str(&json).unwrap()
437    }
438
439    #[rstest]
440    fn test_handler_initializes() {
441        let _handler = create_test_handler();
442    }
443
444    #[rstest]
445    fn test_parse_frame_auth_success() {
446        let value = load_value("ws_auth_success.json");
447        let frame = parse_bybit_ws_frame(value);
448        match frame {
449            BybitWsFrame::Auth(auth) => {
450                assert_eq!(auth.conn_id.as_deref(), Some("cejreaspqfm9se7usbrg-2xh"));
451                assert_eq!(auth.ret_code, Some(0));
452                assert_eq!(auth.success, Some(true));
453            }
454            other => panic!("Expected Auth, was {other:?}"),
455        }
456    }
457
458    #[rstest]
459    fn test_parse_frame_auth_failure() {
460        let value = load_value("ws_auth_failure.json");
461        let frame = parse_bybit_ws_frame(value);
462        match frame {
463            BybitWsFrame::ErrorResponse(resp) => {
464                assert_eq!(resp.ret_code, Some(10003));
465                assert_eq!(resp.ret_msg.as_deref(), Some("Invalid apikey"));
466            }
467            other => panic!("Expected ErrorResponse, was {other:?}"),
468        }
469    }
470
471    #[rstest]
472    fn test_parse_frame_subscription_ack() {
473        let value = load_value("ws_subscription_ack.json");
474        let frame = parse_bybit_ws_frame(value);
475        match frame {
476            BybitWsFrame::Subscription(sub) => {
477                assert!(sub.success);
478                assert_eq!(sub.op, BybitWsOperation::Subscribe);
479                assert_eq!(sub.req_id.as_deref(), Some("sub-orderbook-1"));
480            }
481            other => panic!("Expected Subscription, was {other:?}"),
482        }
483    }
484
485    #[rstest]
486    fn test_parse_frame_subscription_failure() {
487        let value = load_value("ws_subscription_failure.json");
488        let frame = parse_bybit_ws_frame(value);
489        match frame {
490            BybitWsFrame::ErrorResponse(resp) => {
491                assert_eq!(
492                    resp.ret_msg.as_deref(),
493                    Some("Invalid topic: invalid.topic.BTCUSDT")
494                );
495            }
496            other => panic!("Expected ErrorResponse, was {other:?}"),
497        }
498    }
499
500    #[rstest]
501    fn test_parse_frame_order_response() {
502        let value = load_value("ws_order_response.json");
503        let frame = parse_bybit_ws_frame(value);
504        match frame {
505            BybitWsFrame::OrderResponse(resp) => {
506                assert_eq!(resp.op.as_str(), "order.create");
507                assert_eq!(resp.ret_code, 0);
508                assert_eq!(resp.ret_msg, "OK");
509            }
510            other => panic!("Expected OrderResponse, was {other:?}"),
511        }
512    }
513
514    #[rstest]
515    fn test_parse_frame_orderbook() {
516        let value = load_value("ws_orderbook_snapshot.json");
517        let frame = parse_bybit_ws_frame(value);
518        assert!(
519            matches!(frame, BybitWsFrame::Orderbook(_)),
520            "Expected Orderbook, was {frame:?}"
521        );
522    }
523
524    #[rstest]
525    fn test_parse_frame_trade() {
526        let value = load_value("ws_public_trade.json");
527        let frame = parse_bybit_ws_frame(value);
528        assert!(
529            matches!(frame, BybitWsFrame::Trade(_)),
530            "Expected Trade, was {frame:?}"
531        );
532    }
533
534    #[rstest]
535    fn test_parse_frame_kline() {
536        let value = load_value("ws_kline.json");
537        let frame = parse_bybit_ws_frame(value);
538        assert!(
539            matches!(frame, BybitWsFrame::Kline(_)),
540            "Expected Kline, was {frame:?}"
541        );
542    }
543
544    #[rstest]
545    fn test_parse_frame_ticker_linear() {
546        let value = load_value("ws_ticker_linear.json");
547        let frame = parse_bybit_ws_frame(value);
548        assert!(
549            matches!(frame, BybitWsFrame::TickerLinear(_)),
550            "Expected TickerLinear, was {frame:?}"
551        );
552    }
553
554    #[rstest]
555    fn test_parse_frame_ticker_option() {
556        let value = load_value("ws_ticker_option.json");
557        let frame = parse_bybit_ws_frame(value);
558        assert!(
559            matches!(frame, BybitWsFrame::TickerOption(_)),
560            "Expected TickerOption, was {frame:?}"
561        );
562    }
563
564    #[rstest]
565    fn test_parse_frame_account_order() {
566        let value = load_value("ws_account_order.json");
567        let frame = parse_bybit_ws_frame(value);
568        assert!(
569            matches!(frame, BybitWsFrame::AccountOrder(_)),
570            "Expected AccountOrder, was {frame:?}"
571        );
572    }
573
574    #[rstest]
575    fn test_parse_frame_account_execution() {
576        let value = load_value("ws_account_execution.json");
577        let frame = parse_bybit_ws_frame(value);
578        assert!(
579            matches!(frame, BybitWsFrame::AccountExecution(_)),
580            "Expected AccountExecution, was {frame:?}"
581        );
582    }
583
584    #[rstest]
585    fn test_parse_frame_account_wallet() {
586        let value = load_value("ws_account_wallet.json");
587        let frame = parse_bybit_ws_frame(value);
588        assert!(
589            matches!(frame, BybitWsFrame::AccountWallet(_)),
590            "Expected AccountWallet, was {frame:?}"
591        );
592    }
593
594    #[rstest]
595    fn test_parse_frame_account_position() {
596        let value = load_value("ws_account_position.json");
597        let frame = parse_bybit_ws_frame(value);
598        assert!(
599            matches!(frame, BybitWsFrame::AccountPosition(_)),
600            "Expected AccountPosition, was {frame:?}"
601        );
602    }
603
604    #[rstest]
605    fn test_parse_frame_unknown_message() {
606        let value: serde_json::Value = serde_json::json!({"foo": "bar"});
607        let frame = parse_bybit_ws_frame(value);
608        assert!(
609            matches!(frame, BybitWsFrame::Unknown(_)),
610            "Expected Unknown, was {frame:?}"
611        );
612    }
613
614    #[rstest]
615    fn test_parse_raw_reconnected_signal() {
616        let msg = Message::Text(nautilus_network::RECONNECTED.to_string().into());
617        let result = BybitWsFeedHandler::parse_raw_frame(msg);
618        assert!(
619            matches!(result, Some(BybitWsFrame::Reconnected)),
620            "Expected Some(Reconnected), was {result:?}"
621        );
622    }
623
624    #[rstest]
625    fn test_parse_raw_pong_text() {
626        let msg = Message::Text("pong".into());
627        let result = BybitWsFeedHandler::parse_raw_frame(msg);
628        assert!(result.is_none(), "Expected None for pong, was {result:?}");
629    }
630
631    #[rstest]
632    fn test_parse_raw_json_pong_message() {
633        let msg = Message::Text(
634            r#"{"args":["1777226678908"],"conn_id":"yzr7jz02gws1vh60mk5m-hxqdp","op":"pong"}"#
635                .into(),
636        );
637        let result = BybitWsFeedHandler::parse_raw_frame(msg);
638        assert!(
639            result.is_none(),
640            "Expected None for JSON pong, was {result:?}"
641        );
642    }
643
644    #[rstest]
645    fn test_parse_raw_valid_json() {
646        let json = load_test_json("ws_public_trade.json");
647        let msg = Message::Text(json.into());
648        let result = BybitWsFeedHandler::parse_raw_frame(msg);
649        assert!(
650            matches!(result, Some(BybitWsFrame::Trade(_))),
651            "Expected Some(Trade), was {result:?}"
652        );
653    }
654
655    #[rstest]
656    fn test_parse_raw_invalid_json() {
657        let msg = Message::Text("not valid json".into());
658        let result = BybitWsFeedHandler::parse_raw_frame(msg);
659        assert!(
660            result.is_none(),
661            "Expected None for invalid JSON, was {result:?}"
662        );
663    }
664
665    #[rstest]
666    fn test_parse_raw_binary_message() {
667        let msg = Message::Binary(vec![0x01, 0x02].into());
668        let result = BybitWsFeedHandler::parse_raw_frame(msg);
669        assert!(result.is_none(), "Expected None for binary, was {result:?}");
670    }
671
672    #[rstest]
673    fn test_subscription_ack_with_req_id_confirms_only_that_topic() {
674        let handler = create_test_handler();
675        handler.subscriptions.mark_subscribe("orderbook.50.BTCUSDT");
676        handler.subscriptions.mark_subscribe("publicTrade.BTCUSDT");
677
678        let ack = BybitWsSubscriptionMsg {
679            success: true,
680            op: BybitWsOperation::Subscribe,
681            conn_id: None,
682            req_id: Some("orderbook.50.BTCUSDT".to_string()),
683            ret_msg: None,
684        };
685
686        handler.handle_subscription_ack(&ack);
687
688        // Only orderbook should be confirmed, trade stays pending
689        assert!(
690            handler
691                .subscriptions
692                .pending_subscribe_topics()
693                .contains(&"publicTrade.BTCUSDT".to_string())
694        );
695        assert!(
696            !handler
697                .subscriptions
698                .pending_subscribe_topics()
699                .contains(&"orderbook.50.BTCUSDT".to_string())
700        );
701    }
702
703    #[rstest]
704    fn test_subscription_failure_with_req_id_marks_only_that_topic() {
705        let handler = create_test_handler();
706        handler.subscriptions.mark_subscribe("orderbook.50.BTCUSDT");
707        handler.subscriptions.mark_subscribe("publicTrade.BTCUSDT");
708
709        let ack = BybitWsSubscriptionMsg {
710            success: false,
711            op: BybitWsOperation::Subscribe,
712            conn_id: None,
713            req_id: Some("orderbook.50.BTCUSDT".to_string()),
714            ret_msg: Some("Invalid topic".to_string()),
715        };
716
717        handler.handle_subscription_ack(&ack);
718
719        // Orderbook should be marked as failed (back to pending for retry)
720        // Trade should remain pending (unaffected)
721        let pending = handler.subscriptions.pending_subscribe_topics();
722        assert!(pending.contains(&"orderbook.50.BTCUSDT".to_string()));
723        assert!(pending.contains(&"publicTrade.BTCUSDT".to_string()));
724    }
725
726    #[rstest]
727    fn test_error_response_with_subscribe_op_triggers_mark_failure() {
728        let handler = create_test_handler();
729        handler
730            .subscriptions
731            .mark_subscribe("invalid.topic.BTCUSDT");
732
733        let resp = BybitWsResponse {
734            op: Some(BybitWsOperation::Subscribe),
735            topic: None,
736            success: Some(false),
737            conn_id: None,
738            req_id: Some("invalid.topic.BTCUSDT".to_string()),
739            ret_code: Some(10001),
740            ret_msg: Some("Invalid topic".to_string()),
741        };
742
743        handler.handle_subscription_error(&resp);
744
745        // Topic should still be in pending (mark_failure moves confirmed -> pending)
746        let pending = handler.subscriptions.pending_subscribe_topics();
747        assert!(pending.contains(&"invalid.topic.BTCUSDT".to_string()));
748    }
749
750    #[rstest]
751    fn test_already_subscribed_error_confirms_topic() {
752        let handler = create_test_handler();
753        handler.subscriptions.mark_subscribe("tickers.ETHUSDT");
754
755        let resp = BybitWsResponse {
756            op: Some(BybitWsOperation::Subscribe),
757            topic: None,
758            success: Some(false),
759            conn_id: None,
760            req_id: Some("tickers.ETHUSDT".to_string()),
761            ret_code: Some(10001),
762            ret_msg: Some("error:already subscribed,topic:tickers.ETHUSDT".to_string()),
763        };
764
765        handler.handle_subscription_error(&resp);
766
767        let pending = handler.subscriptions.pending_subscribe_topics();
768        assert!(!pending.contains(&"tickers.ETHUSDT".to_string()));
769        let symbols = handler.subscriptions.confirmed();
770        let entry = symbols
771            .get(&Ustr::from("tickers"))
772            .expect("channel present");
773        assert!(entry.contains(&Ustr::from("ETHUSDT")));
774    }
775
776    #[rstest]
777    fn test_subscription_ack_without_req_id_confirms_all_pending() {
778        let handler = create_test_handler();
779        handler.subscriptions.mark_subscribe("orderbook.50.BTCUSDT");
780        handler.subscriptions.mark_subscribe("publicTrade.BTCUSDT");
781
782        let ack = BybitWsSubscriptionMsg {
783            success: true,
784            op: BybitWsOperation::Subscribe,
785            conn_id: None,
786            req_id: None,
787            ret_msg: None,
788        };
789
790        handler.handle_subscription_ack(&ack);
791
792        // Both should be confirmed when no req_id
793        assert!(handler.subscriptions.pending_subscribe_topics().is_empty());
794    }
795}