Skip to main content

nautilus_bybit/
data.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//! Live market data client implementation for the Bybit adapter.
17
18use std::{
19    future::Future,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24    time::Duration,
25};
26
27use ahash::{AHashMap, AHashSet};
28use anyhow::Context;
29use futures_util::{StreamExt, pin_mut};
30use nautilus_common::{
31    cache::quote::QuoteCache,
32    clients::DataClient,
33    live::{runner::get_data_event_sender, sender::EventSender},
34    messages::{
35        DataEvent,
36        data::{
37            BarsResponse, BookResponse, DataResponse, FundingRatesResponse, InstrumentResponse,
38            InstrumentsResponse, OptionChainReferencePriceResponse, RequestBars,
39            RequestBookSnapshot, RequestFundingRates, RequestInstrument, RequestInstruments,
40            RequestOptionChainReferencePrice, RequestTrades, SubscribeBars, SubscribeBookDeltas,
41            SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
42            SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices,
43            SubscribeOptionGreeks, SubscribeQuotes, SubscribeTrades, TradesResponse,
44            UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeFundingRates,
45            UnsubscribeIndexPrices, UnsubscribeInstrument, UnsubscribeInstrumentStatus,
46            UnsubscribeInstruments, UnsubscribeMarkPrices, UnsubscribeOptionGreeks,
47            UnsubscribeQuotes, UnsubscribeTrades,
48        },
49    },
50};
51use nautilus_core::{
52    AtomicMap, AtomicSet,
53    datetime::datetime_to_unix_nanos,
54    time::{AtomicTime, get_atomic_clock_realtime},
55};
56use nautilus_live::{
57    SocketControlFactory,
58    task::{TaskGroup, TaskGroupGuard},
59};
60use nautilus_model::{
61    data::{BarType, Data},
62    enums::{BookType, MarketStatusAction},
63    identifiers::{ClientId, InstrumentId, Venue},
64    instruments::{Instrument, InstrumentAny},
65    orderbook::book::OrderBook,
66    types::Price,
67};
68use rust_decimal::Decimal;
69use tokio_util::sync::CancellationToken;
70use ustr::Ustr;
71
72use crate::{
73    common::{
74        consts::{
75            BYBIT_BOOK_DEPTHS, BYBIT_DEFAULT_ORDERBOOK_DEPTH, BYBIT_QUOTE_DEPTH, BYBIT_VENUE,
76        },
77        enums::BybitProductType,
78        instruments::diff_and_emit_instruments,
79        parse::{extract_raw_symbol, make_bybit_symbol},
80        status::{diff_and_emit_statuses, emit_status},
81    },
82    config::BybitDataClientConfig,
83    http::client::BybitHttpClient,
84    websocket::{
85        client::BybitWebSocketClient,
86        messages::BybitWsMessage,
87        parse::{
88            parse_kline_topic, parse_millis_i64, parse_orderbook_deltas, parse_orderbook_quote,
89            parse_orderbook_topic, parse_ticker_linear_funding, parse_ticker_linear_index_price,
90            parse_ticker_linear_mark_price, parse_ticker_option_greeks,
91            parse_ticker_option_index_price, parse_ticker_option_mark_price,
92            parse_ticker_option_quote, parse_ws_kline_bar, parse_ws_trade_tick,
93        },
94    },
95};
96
97/// Live market data client for Bybit.
98#[derive(Debug)]
99pub struct BybitDataClient {
100    client_id: ClientId,
101    config: BybitDataClientConfig,
102    http_client: BybitHttpClient,
103    ws_clients: Vec<BybitWebSocketClient>,
104    is_connected: AtomicBool,
105    cancellation_token: CancellationToken,
106    session_tasks: TaskGroup,
107    command_tasks: TaskGroup,
108    shutdown_errors: Vec<String>,
109    data_sender: EventSender<DataEvent>,
110    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
111    book_depths: Arc<AtomicMap<InstrumentId, u32>>,
112    quote_subs: Arc<AtomicSet<InstrumentId>>,
113    ticker_subs: Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
114    trade_subs: Arc<AtomicSet<InstrumentId>>,
115    option_greeks_subs: Arc<AtomicSet<InstrumentId>>,
116    instrument_status_subs: Arc<AtomicSet<InstrumentId>>,
117    status_cache: Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
118    instrument_subs: Arc<AtomicSet<InstrumentId>>,
119    subscribe_all_instruments: Arc<AtomicBool>,
120    clock: &'static AtomicTime,
121}
122
123impl BybitDataClient {
124    /// Creates a new [`BybitDataClient`] instance.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the client fails to initialize.
129    pub fn new(client_id: ClientId, config: BybitDataClientConfig) -> anyhow::Result<Self> {
130        let clock = get_atomic_clock_realtime();
131        let data_sender = get_data_event_sender();
132        let socket_factory = SocketControlFactory::new(client_id, Some(*BYBIT_VENUE));
133        let proxy_url = config
134            .proxy_url
135            .as_ref()
136            .map(|value| value.expose_secret().to_owned());
137
138        let http_client = if let (Some(api_key), Some(api_secret)) =
139            (config.api_key.clone(), config.api_secret.clone())
140        {
141            BybitHttpClient::with_credentials(
142                api_key.into_inner(),
143                api_secret.into_inner(),
144                Some(config.http_base_url()),
145                config.http_timeout_secs,
146                config.max_retries,
147                config.retry_delay_initial_ms,
148                config.retry_delay_max_ms,
149                config.recv_window_ms,
150                proxy_url.clone(),
151            )?
152        } else {
153            BybitHttpClient::new(
154                Some(config.http_base_url()),
155                config.http_timeout_secs,
156                config.max_retries,
157                config.retry_delay_initial_ms,
158                config.retry_delay_max_ms,
159                config.recv_window_ms,
160                proxy_url.clone(),
161            )?
162        };
163
164        // Create a WebSocket client for each product type (default to Linear if empty)
165        let product_types = if config.product_types.is_empty() {
166            vec![BybitProductType::Linear]
167        } else {
168            config.product_types.clone()
169        };
170
171        let ws_clients: Vec<BybitWebSocketClient> = product_types
172            .iter()
173            .map(|product_type| {
174                BybitWebSocketClient::new_public_with(
175                    *product_type,
176                    config.environment,
177                    Some(config.ws_public_url_for(*product_type)),
178                    config.heartbeat_interval_secs,
179                    config.transport_backend,
180                    proxy_url.clone(),
181                )
182                .with_socket_control(
183                    socket_factory.control(format!("bybit-{}-data-streams", product_type.as_str())),
184                )
185            })
186            .collect();
187
188        let session_tasks = TaskGroup::new();
189        let command_tasks = TaskGroup::new();
190
191        Ok(Self {
192            client_id,
193            config,
194            http_client,
195            ws_clients,
196            is_connected: AtomicBool::new(false),
197            cancellation_token: session_tasks.cancellation_token(),
198            session_tasks,
199            command_tasks,
200            shutdown_errors: Vec::new(),
201            data_sender,
202            instruments: Arc::new(AtomicMap::new()),
203            book_depths: Arc::new(AtomicMap::new()),
204            quote_subs: Arc::new(AtomicSet::new()),
205            ticker_subs: Arc::new(AtomicMap::new()),
206            trade_subs: Arc::new(AtomicSet::new()),
207            option_greeks_subs: Arc::new(AtomicSet::new()),
208            instrument_status_subs: Arc::new(AtomicSet::new()),
209            status_cache: Arc::new(AtomicMap::new()),
210            instrument_subs: Arc::new(AtomicSet::new()),
211            subscribe_all_instruments: Arc::new(AtomicBool::new(false)),
212            clock,
213        })
214    }
215
216    fn venue(&self) -> Venue {
217        *BYBIT_VENUE
218    }
219
220    fn get_ws_client_for_product(
221        &self,
222        product_type: BybitProductType,
223    ) -> Option<&BybitWebSocketClient> {
224        self.ws_clients
225            .iter()
226            .find(|ws| ws.product_type() == Some(product_type))
227    }
228
229    fn get_product_type_for_instrument(
230        &self,
231        instrument_id: InstrumentId,
232    ) -> Option<BybitProductType> {
233        let guard = self.instruments.load();
234        guard
235            .get(&instrument_id)
236            .and_then(|_| BybitProductType::from_suffix(instrument_id.symbol.as_str()))
237    }
238
239    fn spawn_command<F>(&self, future: F)
240    where
241        F: Future<Output = ()> + Send + 'static,
242    {
243        if let Err(e) = self.command_tasks.spawn(future) {
244            log::warn!("Skipping Bybit data command after shutdown began: {e}");
245        }
246    }
247
248    async fn finish_tasks(&self) -> anyhow::Result<()> {
249        let (session_result, command_result) = tokio::join!(
250            self.session_tasks
251                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
252            self.command_tasks
253                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
254        );
255        let mut errors = Vec::new();
256        if let Err(e) = session_result {
257            errors.push(format!("failed to finish Bybit data session tasks: {e}"));
258        }
259
260        if let Err(e) = command_result {
261            errors.push(format!("failed to finish Bybit data command tasks: {e}"));
262        }
263
264        if errors.is_empty() {
265            Ok(())
266        } else {
267            anyhow::bail!(errors.join("; "))
268        }
269    }
270
271    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
272        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
273            self.teardown_partial_connect().await?;
274            self.session_tasks
275                .start_generation()
276                .context("failed to start Bybit data session task generation")?;
277            self.command_tasks
278                .start_generation()
279                .context("failed to start Bybit data command task generation")?;
280            self.cancellation_token = self.session_tasks.cancellation_token();
281        }
282        Ok(())
283    }
284
285    fn spawn_ws<F>(&self, fut: F, context: &'static str)
286    where
287        F: Future<Output = anyhow::Result<()>> + Send + 'static,
288    {
289        let future = async move {
290            if let Err(e) = fut.await {
291                log::error!("{context}: {e:?}");
292            }
293        };
294
295        if let Err(e) = self.command_tasks.spawn(future) {
296            log::warn!("Skipping Bybit {context} after shutdown began: {e}");
297        }
298    }
299
300    fn spawn_instrument_polling(
301        &self,
302        product_types: &[BybitProductType],
303        poll_secs: u64,
304    ) -> anyhow::Result<()> {
305        let http = self.http_client.clone();
306        let sender = self.data_sender.clone();
307        let instruments = self.instruments.clone();
308        let status_cache = self.status_cache.clone();
309        let status_subs = self.instrument_status_subs.clone();
310        let instrument_subs = self.instrument_subs.clone();
311        let subscribe_all_instruments = self.subscribe_all_instruments.clone();
312        let cancel = self.cancellation_token.clone();
313        let clock = self.clock;
314        let product_types = product_types.to_vec();
315
316        let future = async move {
317            let mut interval = tokio::time::interval(Duration::from_secs(poll_secs));
318            interval.tick().await; // Skip first immediate tick
319
320            loop {
321                tokio::select! {
322                    _ = interval.tick() => {
323                        let all_flag = subscribe_all_instruments.load(Ordering::Relaxed);
324                        let want_instruments = all_flag || !instrument_subs.is_empty();
325                        let want_statuses = !status_subs.is_empty();
326                        if !want_instruments && !want_statuses {
327                            continue;
328                        }
329
330                        let mut all_statuses = AHashMap::new();
331
332                        if want_instruments {
333                            let subs: Option<AHashSet<InstrumentId>> = if all_flag {
334                                None
335                            } else {
336                                Some((**instrument_subs.load()).clone())
337                            };
338                            let mut inst_cache = (**instruments.load()).clone();
339
340                            for &pt in &product_types {
341                                match http.request_instruments_with_statuses(pt).await {
342                                    Ok((fetched, statuses)) => {
343                                        diff_and_emit_instruments(
344                                            &fetched, &mut inst_cache, subs.as_ref(), &sender,
345                                        );
346
347                                        for (id, action) in statuses {
348                                            if inst_cache.contains_key(&id) {
349                                                all_statuses.insert(id, action);
350                                            }
351                                        }
352                                    }
353                                    Err(e) => {
354                                        log::warn!("Bybit instrument poll failed for {pt:?}: {e}");
355                                    }
356                                }
357                            }
358
359                            instruments.store(inst_cache);
360                        } else {
361                            for &pt in &product_types {
362                                match http.request_instrument_statuses(pt).await {
363                                    Ok(new_statuses) => {
364                                        let inst_guard = instruments.load();
365                                        for (id, action) in new_statuses {
366                                            if inst_guard.contains_key(&id) {
367                                                all_statuses.insert(id, action);
368                                            }
369                                        }
370                                    }
371                                    Err(e) => {
372                                        log::warn!("Bybit instrument status poll failed for {pt:?}: {e}");
373                                    }
374                                }
375                            }
376                        }
377
378                        if want_statuses {
379                            let ts = clock.get_time_ns();
380                            let mut cache = (**status_cache.load()).clone();
381                            let subs_guard = status_subs.load();
382                            diff_and_emit_statuses(
383                                &all_statuses, &mut cache, Some(&subs_guard), &sender, ts, ts,
384                            );
385                            status_cache.store(cache);
386                        }
387                    }
388                    () = cancel.cancelled() => {
389                        log::debug!("Bybit instrument polling task cancelled");
390                        break;
391                    }
392                }
393            }
394        };
395
396        self.session_tasks
397            .spawn(future)
398            .context("failed to register Bybit instrument polling task")?;
399        log::debug!("Instrument polling started: interval={poll_secs}s");
400        Ok(())
401    }
402
403    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
404        self.session_tasks.begin_shutdown();
405        self.command_tasks.begin_shutdown();
406        for ws_client in &self.ws_clients {
407            ws_client.begin_shutdown();
408        }
409
410        for ws_client in &mut self.ws_clients {
411            if let Err(e) = ws_client.close().await {
412                self.shutdown_errors.push(e.to_string());
413            }
414        }
415
416        if let Err(e) = self.finish_tasks().await {
417            self.shutdown_errors.push(e.to_string());
418        }
419        self.is_connected.store(false, Ordering::Release);
420
421        if self.shutdown_errors.is_empty() {
422            Ok(())
423        } else {
424            let errors = std::mem::take(&mut self.shutdown_errors);
425            anyhow::bail!("Bybit data shutdown failed: {}", errors.join("; "))
426        }
427    }
428}
429
430fn send_data(sender: &EventSender<DataEvent>, data: Data) {
431    if let Err(e) = sender.send(DataEvent::Data(data)) {
432        log::error!("Failed to emit data event: {e}");
433    }
434}
435
436fn validate_orderbook_depth(depth: u32) -> anyhow::Result<()> {
437    if !BYBIT_BOOK_DEPTHS.contains(&depth) {
438        anyhow::bail!("invalid depth {depth}; valid values are {BYBIT_BOOK_DEPTHS:?}");
439    }
440
441    Ok(())
442}
443
444/// Cached funding state per symbol: (funding_rate, next_funding_time, funding_interval_hour).
445type FundingCacheEntry = (Option<String>, Option<String>, Option<String>);
446
447#[expect(clippy::too_many_arguments)]
448fn handle_ws_message(
449    message: &BybitWsMessage,
450    data_sender: &EventSender<DataEvent>,
451    instruments: &AHashMap<Ustr, InstrumentAny>,
452    product_type: Option<BybitProductType>,
453    trade_subs: &Arc<AtomicSet<InstrumentId>>,
454    ticker_subs: &Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
455    quote_subs: &Arc<AtomicSet<InstrumentId>>,
456    book_depths: &Arc<AtomicMap<InstrumentId, u32>>,
457    option_greeks_subs: &Arc<AtomicSet<InstrumentId>>,
458    bar_types_cache: &Arc<AtomicMap<String, BarType>>,
459    quote_cache: &mut QuoteCache,
460    funding_cache: &mut AHashMap<Ustr, FundingCacheEntry>,
461    clock: &AtomicTime,
462) {
463    let ts_init = clock.get_time_ns();
464    let resolve = |raw_symbol: &Ustr| -> Option<&InstrumentAny> {
465        let key = product_type.map_or(*raw_symbol, |pt| make_bybit_symbol(raw_symbol, pt));
466        instruments.get(&key)
467    };
468
469    match message {
470        BybitWsMessage::Orderbook(msg) => {
471            let Some(instrument) = resolve(&msg.data.s) else {
472                log::warn!("Unknown symbol in orderbook update: {}", msg.data.s);
473                return;
474            };
475            let instrument_id = instrument.id();
476
477            let Ok((depth, symbol)) = parse_orderbook_topic(msg.topic.as_str()) else {
478                log::warn!("Invalid orderbook topic: {}", msg.topic);
479                return;
480            };
481
482            if symbol != msg.data.s.as_str() {
483                log::warn!(
484                    "Orderbook topic symbol does not match payload: {}",
485                    msg.topic
486                );
487                return;
488            }
489
490            if book_depths.load().get(&instrument_id) == Some(&depth) {
491                match parse_orderbook_deltas(msg, instrument, ts_init) {
492                    Ok(deltas) => {
493                        send_data(data_sender, Data::BookDeltas(Box::new(deltas)));
494                    }
495                    Err(e) => log::error!("Failed to parse orderbook deltas: {e}"),
496                }
497            }
498
499            if depth == BYBIT_QUOTE_DEPTH && quote_subs.contains(&instrument_id) {
500                match parse_orderbook_quote(msg, instrument, ts_init) {
501                    Ok(quote) => {
502                        quote_cache.insert(instrument_id, quote);
503                        send_data(data_sender, Data::Quote(quote));
504                    }
505                    Err(e) => log::error!("Failed to parse orderbook quote: {e}"),
506                }
507            }
508        }
509        BybitWsMessage::Trade(msg) => {
510            for trade in &msg.data {
511                let Some(instrument) = resolve(&trade.s) else {
512                    continue;
513                };
514                let instrument_id = instrument.id();
515                if !trade_subs.contains(&instrument_id) {
516                    continue;
517                }
518
519                match parse_ws_trade_tick(trade, instrument, ts_init) {
520                    Ok(tick) => send_data(data_sender, Data::Trade(tick)),
521                    Err(e) => log::error!("Failed to parse trade tick: {e}"),
522                }
523            }
524        }
525        BybitWsMessage::Kline(msg) => {
526            let Ok((_, raw_symbol)) = parse_kline_topic(msg.topic.as_str()) else {
527                log::warn!("Invalid kline topic: {}", msg.topic);
528                return;
529            };
530            let ustr_symbol = Ustr::from(raw_symbol);
531            let Some(instrument) = resolve(&ustr_symbol) else {
532                log::warn!("Unknown symbol in kline update: {raw_symbol}");
533                return;
534            };
535            let topic_key = msg.topic.as_str();
536            let Some(bar_type) = bar_types_cache.load().get(topic_key).copied() else {
537                log::warn!("No bar type cached for kline topic: {topic_key}");
538                return;
539            };
540
541            for kline in &msg.data {
542                if !kline.confirm {
543                    continue;
544                }
545
546                match parse_ws_kline_bar(kline, instrument, bar_type, true, ts_init) {
547                    Ok(bar) => send_data(data_sender, Data::Bar(bar)),
548                    Err(e) => log::error!("Failed to parse kline bar: {e}"),
549                }
550            }
551        }
552        BybitWsMessage::TickerLinear(msg) => {
553            let Some(instrument) = resolve(&msg.data.symbol) else {
554                log::warn!("Unknown symbol in ticker update: {}", msg.data.symbol);
555                return;
556            };
557            let instrument_id = instrument.id();
558            let subs = ticker_subs.load();
559            let sub_set = subs.get(&instrument_id);
560
561            let ts_event = match parse_millis_i64(msg.ts, "ticker.ts") {
562                Ok(ts) => ts,
563                Err(e) => {
564                    log::error!("Failed to parse ticker timestamp: {e}");
565                    return;
566                }
567            };
568
569            if sub_set.is_some_and(|s| s.contains("funding"))
570                && matches!(instrument, InstrumentAny::CryptoPerpetual(_))
571            {
572                let cache_entry = funding_cache
573                    .entry(msg.data.symbol)
574                    .or_insert((None, None, None));
575                let mut changed = false;
576
577                if let Some(rate) = &msg.data.funding_rate
578                    && cache_entry.0.as_ref() != Some(rate)
579                {
580                    cache_entry.0 = Some(rate.clone());
581                    changed = true;
582                }
583
584                if let Some(next_time) = &msg.data.next_funding_time
585                    && cache_entry.1.as_ref() != Some(next_time)
586                {
587                    cache_entry.1 = Some(next_time.clone());
588                    changed = true;
589                }
590
591                if let Some(interval) = &msg.data.funding_interval_hour {
592                    cache_entry.2 = Some(interval.clone());
593                }
594
595                if changed && cache_entry.0.is_some() {
596                    let mut merged = msg.data.clone();
597
598                    if merged.funding_rate.is_none() {
599                        merged.funding_rate.clone_from(&cache_entry.0);
600                    }
601
602                    if merged.next_funding_time.is_none() {
603                        merged.next_funding_time.clone_from(&cache_entry.1);
604                    }
605
606                    if merged.funding_interval_hour.is_none() {
607                        merged.funding_interval_hour.clone_from(&cache_entry.2);
608                    }
609
610                    match parse_ticker_linear_funding(&merged, instrument_id, ts_event, ts_init) {
611                        Ok(update) => {
612                            if let Err(e) = data_sender.send(DataEvent::FundingRate(update)) {
613                                log::error!("Failed to emit funding rate event: {e}");
614                            }
615                        }
616                        Err(e) => log::error!("Failed to parse ticker linear funding: {e}"),
617                    }
618                }
619            }
620
621            if sub_set.is_some_and(|s| s.contains("mark_prices")) && msg.data.mark_price.is_some() {
622                match parse_ticker_linear_mark_price(&msg.data, instrument, ts_event, ts_init) {
623                    Ok(update) => send_data(data_sender, Data::MarkPrice(update)),
624                    Err(e) => log::debug!("Skipping mark price update: {e}"),
625                }
626            }
627
628            if sub_set.is_some_and(|s| s.contains("index_prices")) && msg.data.index_price.is_some()
629            {
630                match parse_ticker_linear_index_price(&msg.data, instrument, ts_event, ts_init) {
631                    Ok(update) => send_data(data_sender, Data::IndexPrice(update)),
632                    Err(e) => log::debug!("Skipping index price update: {e}"),
633                }
634            }
635        }
636        BybitWsMessage::TickerOption(msg) => {
637            let Some(instrument) = resolve(&msg.data.symbol) else {
638                log::warn!(
639                    "Unknown symbol in option ticker update: {}",
640                    msg.data.symbol
641                );
642                return;
643            };
644            let instrument_id = instrument.id();
645            let subs = ticker_subs.load();
646            let sub_set = subs.get(&instrument_id);
647
648            if sub_set.is_some_and(|s| s.contains("quotes")) {
649                match parse_ticker_option_quote(msg, instrument, ts_init) {
650                    Ok(quote) => {
651                        let last = quote_cache.get(&instrument_id);
652                        if last.is_none_or(|q| *q != quote) {
653                            quote_cache.insert(instrument_id, quote);
654                            send_data(data_sender, Data::Quote(quote));
655                        }
656                    }
657                    Err(e) => log::error!("Failed to parse ticker option quote: {e}"),
658                }
659            }
660
661            if sub_set.is_some_and(|s| s.contains("mark_prices")) {
662                match parse_ticker_option_mark_price(msg, instrument, ts_init) {
663                    Ok(update) => send_data(data_sender, Data::MarkPrice(update)),
664                    Err(e) => log::error!("Failed to parse ticker option mark price: {e}"),
665                }
666            }
667
668            if sub_set.is_some_and(|s| s.contains("index_prices")) {
669                match parse_ticker_option_index_price(msg, instrument, ts_init) {
670                    Ok(update) => send_data(data_sender, Data::IndexPrice(update)),
671                    Err(e) => log::error!("Failed to parse ticker option index price: {e}"),
672                }
673            }
674
675            if option_greeks_subs.contains(&instrument_id) {
676                match parse_ticker_option_greeks(msg, instrument, ts_init) {
677                    Ok(greeks) => {
678                        if let Err(e) = data_sender.send(DataEvent::OptionGreeks(greeks)) {
679                            log::error!("Failed to send option greeks: {e}");
680                        }
681                    }
682                    Err(e) => log::error!("Failed to parse option greeks: {e}"),
683                }
684            }
685        }
686        BybitWsMessage::Reconnected => {
687            quote_cache.clear();
688            funding_cache.clear();
689            log::info!("WebSocket reconnected, cleared caches");
690        }
691        BybitWsMessage::Error(e) => {
692            log::warn!(
693                "Bybit WebSocket error: code={} message={}",
694                e.code,
695                e.message
696            );
697        }
698        BybitWsMessage::Auth(_)
699        | BybitWsMessage::OrderResponse(_)
700        | BybitWsMessage::AccountOrder(_)
701        | BybitWsMessage::AccountExecution(_)
702        | BybitWsMessage::AccountExecutionFast(_)
703        | BybitWsMessage::AccountWallet(_)
704        | BybitWsMessage::AccountPosition(_) => {}
705    }
706}
707
708fn upsert_instrument(
709    cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
710    instrument: InstrumentAny,
711) {
712    cache.insert(instrument.id(), instrument);
713}
714
715#[async_trait::async_trait(?Send)]
716impl DataClient for BybitDataClient {
717    fn client_id(&self) -> ClientId {
718        self.client_id
719    }
720
721    fn venue(&self) -> Option<Venue> {
722        Some(self.venue())
723    }
724
725    fn start(&mut self) -> anyhow::Result<()> {
726        log::info!(
727            "Started: client_id={}, product_types={:?}, environment={:?}, proxy_url={:?}",
728            self.client_id,
729            self.config.product_types,
730            self.config.environment,
731            self.config.proxy_url,
732        );
733        Ok(())
734    }
735
736    fn stop(&mut self) -> anyhow::Result<()> {
737        log::info!("Stopping {id}", id = self.client_id);
738        self.session_tasks.begin_shutdown();
739        self.command_tasks.begin_shutdown();
740        for ws_client in &self.ws_clients {
741            ws_client.begin_shutdown();
742        }
743        self.is_connected.store(false, Ordering::Relaxed);
744        Ok(())
745    }
746
747    fn reset(&mut self) -> anyhow::Result<()> {
748        log::debug!("Resetting {id}", id = self.client_id);
749        self.session_tasks.begin_shutdown();
750        self.command_tasks.begin_shutdown();
751        for ws_client in &self.ws_clients {
752            ws_client.begin_shutdown();
753        }
754        self.is_connected.store(false, Ordering::Relaxed);
755        self.book_depths.store(AHashMap::new());
756        self.quote_subs.store(AHashSet::new());
757        self.ticker_subs.store(AHashMap::new());
758        self.option_greeks_subs.store(AHashSet::new());
759        self.instrument_status_subs.store(AHashSet::new());
760        self.status_cache.store(AHashMap::new());
761        self.instrument_subs.store(AHashSet::new());
762        self.subscribe_all_instruments
763            .store(false, Ordering::Relaxed);
764        Ok(())
765    }
766
767    fn dispose(&mut self) -> anyhow::Result<()> {
768        log::debug!("Disposing {id}", id = self.client_id);
769        self.stop()
770    }
771
772    async fn connect(&mut self) -> anyhow::Result<()> {
773        if self.is_connected() && self.session_tasks.is_open() && self.command_tasks.is_open() {
774            return Ok(());
775        }
776
777        self.prepare_task_groups().await?;
778        let ws_clients = self.ws_clients.clone();
779        let setup_guard =
780            TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
781                for ws_client in ws_clients {
782                    ws_client.begin_shutdown();
783                }
784            });
785
786        let product_types = if self.config.product_types.is_empty() {
787            vec![BybitProductType::Linear]
788        } else {
789            self.config.product_types.clone()
790        };
791
792        let mut all_instruments = Vec::new();
793
794        for product_type in &product_types {
795            let fetched = self
796                .http_client
797                .request_instruments(*product_type, None, None)
798                .await
799                .with_context(|| {
800                    format!("failed to request Bybit instruments for {product_type:?}")
801                })?;
802
803            self.http_client.cache_instruments(&fetched);
804
805            self.instruments.rcu(|m| {
806                for instrument in &fetched {
807                    m.insert(instrument.id(), instrument.clone());
808                }
809            });
810
811            all_instruments.extend(fetched);
812        }
813
814        // Seed instrument status cache from initial fetch
815        if self
816            .config
817            .instrument_poll_interval_secs
818            .is_some_and(|s| s > 0)
819        {
820            // Collect all statuses first (without holding the lock across await)
821            let mut collected_statuses = Vec::new();
822
823            for product_type in &product_types {
824                match self
825                    .http_client
826                    .request_instrument_statuses(*product_type)
827                    .await
828                {
829                    Ok(statuses) => collected_statuses.push(statuses),
830                    Err(e) => {
831                        log::warn!(
832                            "Failed to seed instrument status cache for {product_type:?}: {e}"
833                        );
834                    }
835                }
836            }
837
838            let inst_guard = self.instruments.load();
839            let mut status_map = AHashMap::new();
840
841            for statuses in collected_statuses {
842                for (id, action) in statuses {
843                    if inst_guard.contains_key(&id) {
844                        status_map.insert(id, action);
845                    }
846                }
847            }
848            log::debug!(
849                "Seeded instrument status cache with {} entries",
850                status_map.len()
851            );
852            self.status_cache.store(status_map);
853        }
854
855        for instrument in all_instruments {
856            if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
857                log::warn!("Failed to send instrument: {e}");
858            }
859        }
860
861        // Build instruments map keyed by full Nautilus symbol for parsing
862        let instruments_by_symbol: Arc<AHashMap<Ustr, InstrumentAny>> = {
863            let guard = self.instruments.load();
864            let mut map = AHashMap::new();
865            for instrument in guard.values() {
866                map.insert(instrument.id().symbol.inner(), instrument.clone());
867            }
868            Arc::new(map)
869        };
870
871        let session_result = async {
872            for ws_client in &mut self.ws_clients {
873                ws_client
874                    .connect()
875                    .await
876                    .context("failed to connect Bybit WebSocket")?;
877                ws_client
878                    .wait_until_active(10.0)
879                    .await
880                    .context("WebSocket did not become active")?;
881
882                let stream = ws_client.stream();
883                let product_type = ws_client.product_type();
884                let sender = self.data_sender.clone();
885                let trade_subs = self.trade_subs.clone();
886                let ticker_subs = self.ticker_subs.clone();
887                let quote_subs = self.quote_subs.clone();
888                let book_depths = self.book_depths.clone();
889                let option_greeks_subs = self.option_greeks_subs.clone();
890                let bar_types_cache = ws_client.bar_types_cache().clone();
891                let instruments = Arc::clone(&instruments_by_symbol);
892                let clock = self.clock;
893                let cancel = self.cancellation_token.clone();
894
895                let future = async move {
896                    let mut quote_cache = QuoteCache::new();
897                    let mut funding_cache: AHashMap<Ustr, FundingCacheEntry> = AHashMap::new();
898
899                    pin_mut!(stream);
900
901                    loop {
902                        tokio::select! {
903                            Some(message) = stream.next() => {
904                                handle_ws_message(
905                                    &message,
906                                    &sender,
907                                    &instruments,
908                                    product_type,
909                                    &trade_subs,
910                                    &ticker_subs,
911                                    &quote_subs,
912                                    &book_depths,
913                                    &option_greeks_subs,
914                                    &bar_types_cache,
915                                    &mut quote_cache,
916                                    &mut funding_cache,
917                                    clock,
918                                );
919                            }
920                            () = cancel.cancelled() => {
921                                log::debug!("WebSocket stream task cancelled");
922                                break;
923                            }
924                        }
925                    }
926                };
927                self.session_tasks
928                    .spawn(future)
929                    .context("failed to register Bybit WebSocket stream task")?;
930            }
931
932            if let Some(poll_secs) = self.config.instrument_poll_interval_secs
933                && poll_secs > 0
934            {
935                self.spawn_instrument_polling(&product_types, poll_secs)?;
936            }
937
938            Ok::<(), anyhow::Error>(())
939        }
940        .await;
941
942        if let Err(e) = session_result {
943            if let Err(teardown_error) = self.teardown_partial_connect().await {
944                return Err(e.context(format!(
945                    "Bybit data startup teardown failed: {teardown_error}"
946                )));
947            }
948            return Err(e);
949        }
950
951        setup_guard.disarm();
952        self.is_connected.store(true, Ordering::Release);
953        log::info!("Connected: client_id={}", self.client_id);
954        Ok(())
955    }
956
957    async fn disconnect(&mut self) -> anyhow::Result<()> {
958        self.session_tasks.begin_shutdown();
959        self.command_tasks.begin_shutdown();
960        for ws_client in &self.ws_clients {
961            ws_client.begin_shutdown();
962        }
963
964        for ws_client in &mut self.ws_clients {
965            if let Err(e) = ws_client.close().await {
966                self.shutdown_errors.push(e.to_string());
967            }
968        }
969
970        // Allow time for unsubscribe confirmations
971        tokio::time::sleep(Duration::from_millis(500)).await;
972
973        if let Err(e) = self.finish_tasks().await {
974            self.shutdown_errors.push(e.to_string());
975        }
976
977        self.book_depths.store(AHashMap::new());
978        self.quote_subs.store(AHashSet::new());
979        self.ticker_subs.store(AHashMap::new());
980        self.trade_subs.store(AHashSet::new());
981        self.option_greeks_subs.store(AHashSet::new());
982        self.instrument_status_subs.store(AHashSet::new());
983        self.status_cache.store(AHashMap::new());
984        self.instrument_subs.store(AHashSet::new());
985        self.subscribe_all_instruments
986            .store(false, Ordering::Relaxed);
987        self.is_connected.store(false, Ordering::Release);
988        log::info!("Disconnected: client_id={}", self.client_id);
989
990        if self.shutdown_errors.is_empty() {
991            Ok(())
992        } else {
993            let errors = std::mem::take(&mut self.shutdown_errors);
994            anyhow::bail!("Bybit data shutdown failed: {}", errors.join("; "))
995        }
996    }
997
998    fn is_connected(&self) -> bool {
999        self.is_connected.load(Ordering::Relaxed)
1000    }
1001
1002    fn is_disconnected(&self) -> bool {
1003        !self.is_connected()
1004    }
1005
1006    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1007        if cmd.book_type != BookType::L2_MBP {
1008            anyhow::bail!("Bybit only supports L2_MBP order book deltas");
1009        }
1010
1011        let depth = cmd
1012            .depth
1013            .map_or(BYBIT_DEFAULT_ORDERBOOK_DEPTH, |d| d.get() as u32);
1014
1015        validate_orderbook_depth(depth)?;
1016
1017        let instrument_id = cmd.instrument_id;
1018        let product_type = self
1019            .get_product_type_for_instrument(instrument_id)
1020            .unwrap_or(BybitProductType::Linear);
1021
1022        let ws = self
1023            .get_ws_client_for_product(product_type)
1024            .context("no WebSocket client for product type")?
1025            .clone();
1026
1027        if let Some(subscribed_depth) = self.book_depths.load().get(&instrument_id) {
1028            anyhow::ensure!(
1029                *subscribed_depth == depth,
1030                "Already subscribed to book depth {subscribed_depth} for {instrument_id}"
1031            );
1032            return Ok(());
1033        }
1034
1035        self.book_depths.insert(instrument_id, depth);
1036        let book_depths = Arc::clone(&self.book_depths);
1037
1038        self.spawn_ws(
1039            async move {
1040                if let Err(e) = ws.subscribe_orderbook(instrument_id, depth).await {
1041                    if let Err(e) = ws.unsubscribe_orderbook(instrument_id, depth).await {
1042                        log::warn!("Failed to unsubscribe after orderbook subscription error: {e}");
1043                    }
1044                    book_depths.rcu(|depths| {
1045                        if depths.get(&instrument_id) == Some(&depth) {
1046                            depths.remove(&instrument_id);
1047                        }
1048                    });
1049                    return Err(e).context("orderbook subscription");
1050                }
1051                Ok(())
1052            },
1053            "order book delta subscription",
1054        );
1055
1056        Ok(())
1057    }
1058
1059    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
1060        let instrument_id = cmd.instrument_id;
1061        let product_type = self
1062            .get_product_type_for_instrument(instrument_id)
1063            .unwrap_or(BybitProductType::Linear);
1064
1065        let ws = self
1066            .get_ws_client_for_product(product_type)
1067            .context("no WebSocket client for product type")?
1068            .clone();
1069
1070        if product_type == BybitProductType::Option {
1071            let mut should_subscribe = false;
1072            self.ticker_subs.rcu(|m| {
1073                let entry = m.entry(instrument_id).or_default();
1074                should_subscribe = entry.is_empty();
1075                entry.insert("quotes");
1076            });
1077
1078            if should_subscribe {
1079                self.spawn_ws(
1080                    async move {
1081                        ws.subscribe_ticker(instrument_id)
1082                            .await
1083                            .context("ticker subscription")
1084                    },
1085                    "quote subscription",
1086                );
1087            }
1088        } else {
1089            if self.quote_subs.contains(&instrument_id) {
1090                return Ok(());
1091            }
1092
1093            self.quote_subs.insert(instrument_id);
1094
1095            self.spawn_ws(
1096                async move {
1097                    ws.subscribe_orderbook(instrument_id, BYBIT_QUOTE_DEPTH)
1098                        .await
1099                        .context("orderbook subscription for quotes")
1100                },
1101                "quote subscription (orderbook)",
1102            );
1103        }
1104        Ok(())
1105    }
1106
1107    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
1108        let instrument_id = cmd.instrument_id;
1109        let product_type = self
1110            .get_product_type_for_instrument(instrument_id)
1111            .unwrap_or(BybitProductType::Linear);
1112
1113        self.trade_subs.insert(instrument_id);
1114
1115        let ws = self
1116            .get_ws_client_for_product(product_type)
1117            .context("no WebSocket client for product type")?
1118            .clone();
1119
1120        self.spawn_ws(
1121            async move {
1122                ws.subscribe_trades(instrument_id)
1123                    .await
1124                    .context("trades subscription")
1125            },
1126            "trade subscription",
1127        );
1128        Ok(())
1129    }
1130
1131    fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
1132        let instrument_id = cmd.instrument_id;
1133        let product_type = self
1134            .get_product_type_for_instrument(instrument_id)
1135            .unwrap_or(BybitProductType::Linear);
1136
1137        if product_type == BybitProductType::Spot || product_type == BybitProductType::Option {
1138            anyhow::bail!("Funding rates not available for {product_type:?} instruments");
1139        }
1140
1141        let guard = self.instruments.load();
1142        if let Some(instrument) = guard.get(&instrument_id)
1143            && !matches!(instrument, InstrumentAny::CryptoPerpetual(_))
1144        {
1145            anyhow::bail!("Funding rates only available for perpetuals, not {instrument_id}");
1146        }
1147
1148        let mut should_subscribe = false;
1149        self.ticker_subs.rcu(|m| {
1150            let entry = m.entry(instrument_id).or_default();
1151            should_subscribe = entry.is_empty();
1152            entry.insert("funding");
1153        });
1154
1155        if should_subscribe {
1156            let ws = self
1157                .get_ws_client_for_product(product_type)
1158                .context("no WebSocket client for product type")?
1159                .clone();
1160
1161            self.spawn_ws(
1162                async move {
1163                    ws.subscribe_ticker(instrument_id)
1164                        .await
1165                        .context("ticker subscription for funding rates")
1166                },
1167                "funding rate subscription",
1168            );
1169        }
1170        Ok(())
1171    }
1172
1173    fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
1174        let instrument_id = cmd.instrument_id;
1175        let product_type = self
1176            .get_product_type_for_instrument(instrument_id)
1177            .unwrap_or(BybitProductType::Linear);
1178
1179        if product_type == BybitProductType::Spot {
1180            anyhow::bail!("Mark prices not available for Spot instruments");
1181        }
1182
1183        let mut should_subscribe = false;
1184        self.ticker_subs.rcu(|m| {
1185            let entry = m.entry(instrument_id).or_default();
1186            should_subscribe = entry.is_empty();
1187            entry.insert("mark_prices");
1188        });
1189
1190        if should_subscribe {
1191            let ws = self
1192                .get_ws_client_for_product(product_type)
1193                .context("no WebSocket client for product type")?
1194                .clone();
1195
1196            self.spawn_ws(
1197                async move {
1198                    ws.subscribe_ticker(instrument_id)
1199                        .await
1200                        .context("ticker subscription for mark prices")
1201                },
1202                "mark price subscription",
1203            );
1204        }
1205        Ok(())
1206    }
1207
1208    fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
1209        let instrument_id = cmd.instrument_id;
1210        let product_type = self
1211            .get_product_type_for_instrument(instrument_id)
1212            .unwrap_or(BybitProductType::Linear);
1213
1214        if product_type == BybitProductType::Spot {
1215            anyhow::bail!("Index prices not available for Spot instruments");
1216        }
1217
1218        let mut should_subscribe = false;
1219        self.ticker_subs.rcu(|m| {
1220            let entry = m.entry(instrument_id).or_default();
1221            should_subscribe = entry.is_empty();
1222            entry.insert("index_prices");
1223        });
1224
1225        if should_subscribe {
1226            let ws = self
1227                .get_ws_client_for_product(product_type)
1228                .context("no WebSocket client for product type")?
1229                .clone();
1230
1231            self.spawn_ws(
1232                async move {
1233                    ws.subscribe_ticker(instrument_id)
1234                        .await
1235                        .context("ticker subscription for index prices")
1236                },
1237                "index price subscription",
1238            );
1239        }
1240        Ok(())
1241    }
1242
1243    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
1244        let bar_type = cmd.bar_type;
1245        let instrument_id = bar_type.instrument_id();
1246        let product_type = self
1247            .get_product_type_for_instrument(instrument_id)
1248            .unwrap_or(BybitProductType::Linear);
1249
1250        if product_type == BybitProductType::Option {
1251            anyhow::bail!("Bybit does not support kline/bar data for options");
1252        }
1253
1254        let ws = self
1255            .get_ws_client_for_product(product_type)
1256            .context("no WebSocket client for product type")?
1257            .clone();
1258
1259        self.spawn_ws(
1260            async move {
1261                ws.subscribe_bars(bar_type)
1262                    .await
1263                    .context("bars subscription")
1264            },
1265            "bar subscription",
1266        );
1267        Ok(())
1268    }
1269
1270    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1271        let instrument_id = cmd.instrument_id;
1272        let Some(depth) = self.book_depths.load().get(&instrument_id).copied() else {
1273            return Ok(());
1274        };
1275
1276        self.book_depths.remove(&instrument_id);
1277
1278        let product_type = self
1279            .get_product_type_for_instrument(instrument_id)
1280            .unwrap_or(BybitProductType::Linear);
1281
1282        let ws = self
1283            .get_ws_client_for_product(product_type)
1284            .context("no WebSocket client for product type")?
1285            .clone();
1286
1287        self.spawn_ws(
1288            async move {
1289                ws.unsubscribe_orderbook(instrument_id, depth)
1290                    .await
1291                    .context("orderbook unsubscribe")
1292            },
1293            "order book unsubscribe",
1294        );
1295        Ok(())
1296    }
1297
1298    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1299        let instrument_id = cmd.instrument_id;
1300        let product_type = self
1301            .get_product_type_for_instrument(instrument_id)
1302            .unwrap_or(BybitProductType::Linear);
1303
1304        let ws = self
1305            .get_ws_client_for_product(product_type)
1306            .context("no WebSocket client for product type")?
1307            .clone();
1308
1309        if product_type == BybitProductType::Option {
1310            let mut should_unsubscribe = false;
1311            self.ticker_subs.rcu(|m| {
1312                if let Some(entry) = m.get_mut(&instrument_id) {
1313                    entry.remove("quotes");
1314                    if entry.is_empty() {
1315                        m.remove(&instrument_id);
1316                        should_unsubscribe = true;
1317                    } else {
1318                        should_unsubscribe = false;
1319                    }
1320                } else {
1321                    should_unsubscribe = false;
1322                }
1323            });
1324
1325            if should_unsubscribe {
1326                self.spawn_ws(
1327                    async move {
1328                        ws.unsubscribe_ticker(instrument_id)
1329                            .await
1330                            .context("ticker unsubscribe")
1331                    },
1332                    "quote unsubscribe",
1333                );
1334            }
1335        } else {
1336            if !self.quote_subs.contains(&instrument_id) {
1337                return Ok(());
1338            }
1339
1340            self.quote_subs.remove(&instrument_id);
1341
1342            self.spawn_ws(
1343                async move {
1344                    ws.unsubscribe_orderbook(instrument_id, BYBIT_QUOTE_DEPTH)
1345                        .await
1346                        .context("orderbook unsubscribe for quotes")
1347                },
1348                "quote unsubscribe (orderbook)",
1349            );
1350        }
1351        Ok(())
1352    }
1353
1354    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1355        let instrument_id = cmd.instrument_id;
1356        let product_type = self
1357            .get_product_type_for_instrument(instrument_id)
1358            .unwrap_or(BybitProductType::Linear);
1359
1360        self.trade_subs.remove(&instrument_id);
1361
1362        let ws = self
1363            .get_ws_client_for_product(product_type)
1364            .context("no WebSocket client for product type")?
1365            .clone();
1366
1367        self.spawn_ws(
1368            async move {
1369                ws.unsubscribe_trades(instrument_id)
1370                    .await
1371                    .context("trades unsubscribe")
1372            },
1373            "trade unsubscribe",
1374        );
1375        Ok(())
1376    }
1377
1378    fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
1379        let instrument_id = cmd.instrument_id;
1380        let product_type = self
1381            .get_product_type_for_instrument(instrument_id)
1382            .unwrap_or(BybitProductType::Linear);
1383
1384        let mut should_unsubscribe = false;
1385        self.ticker_subs.rcu(|m| {
1386            if let Some(entry) = m.get_mut(&instrument_id) {
1387                entry.remove("funding");
1388                if entry.is_empty() {
1389                    m.remove(&instrument_id);
1390                    should_unsubscribe = true;
1391                } else {
1392                    should_unsubscribe = false;
1393                }
1394            } else {
1395                should_unsubscribe = false;
1396            }
1397        });
1398
1399        if should_unsubscribe {
1400            let ws = self
1401                .get_ws_client_for_product(product_type)
1402                .context("no WebSocket client for product type")?
1403                .clone();
1404
1405            self.spawn_ws(
1406                async move {
1407                    ws.unsubscribe_ticker(instrument_id)
1408                        .await
1409                        .context("ticker unsubscribe for funding rates")
1410                },
1411                "funding rate unsubscribe",
1412            );
1413        }
1414        Ok(())
1415    }
1416
1417    fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
1418        let instrument_id = cmd.instrument_id;
1419        let product_type = self
1420            .get_product_type_for_instrument(instrument_id)
1421            .unwrap_or(BybitProductType::Linear);
1422
1423        let mut should_unsubscribe = false;
1424        self.ticker_subs.rcu(|m| {
1425            if let Some(entry) = m.get_mut(&instrument_id) {
1426                entry.remove("mark_prices");
1427                if entry.is_empty() {
1428                    m.remove(&instrument_id);
1429                    should_unsubscribe = true;
1430                } else {
1431                    should_unsubscribe = false;
1432                }
1433            } else {
1434                should_unsubscribe = false;
1435            }
1436        });
1437
1438        if should_unsubscribe {
1439            let ws = self
1440                .get_ws_client_for_product(product_type)
1441                .context("no WebSocket client for product type")?
1442                .clone();
1443
1444            self.spawn_ws(
1445                async move {
1446                    ws.unsubscribe_ticker(instrument_id)
1447                        .await
1448                        .context("ticker unsubscribe for mark prices")
1449                },
1450                "mark price unsubscribe",
1451            );
1452        }
1453        Ok(())
1454    }
1455
1456    fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
1457        let instrument_id = cmd.instrument_id;
1458        let product_type = self
1459            .get_product_type_for_instrument(instrument_id)
1460            .unwrap_or(BybitProductType::Linear);
1461
1462        let mut should_unsubscribe = false;
1463        self.ticker_subs.rcu(|m| {
1464            if let Some(entry) = m.get_mut(&instrument_id) {
1465                entry.remove("index_prices");
1466                if entry.is_empty() {
1467                    m.remove(&instrument_id);
1468                    should_unsubscribe = true;
1469                } else {
1470                    should_unsubscribe = false;
1471                }
1472            } else {
1473                should_unsubscribe = false;
1474            }
1475        });
1476
1477        if should_unsubscribe {
1478            let ws = self
1479                .get_ws_client_for_product(product_type)
1480                .context("no WebSocket client for product type")?
1481                .clone();
1482
1483            self.spawn_ws(
1484                async move {
1485                    ws.unsubscribe_ticker(instrument_id)
1486                        .await
1487                        .context("ticker unsubscribe for index prices")
1488                },
1489                "index price unsubscribe",
1490            );
1491        }
1492        Ok(())
1493    }
1494
1495    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1496        let bar_type = cmd.bar_type;
1497        let instrument_id = bar_type.instrument_id();
1498        let product_type = self
1499            .get_product_type_for_instrument(instrument_id)
1500            .unwrap_or(BybitProductType::Linear);
1501
1502        let ws = self
1503            .get_ws_client_for_product(product_type)
1504            .context("no WebSocket client for product type")?
1505            .clone();
1506
1507        self.spawn_ws(
1508            async move {
1509                ws.unsubscribe_bars(bar_type)
1510                    .await
1511                    .context("bars unsubscribe")
1512            },
1513            "bar unsubscribe",
1514        );
1515        Ok(())
1516    }
1517
1518    fn subscribe_option_greeks(&mut self, cmd: SubscribeOptionGreeks) -> anyhow::Result<()> {
1519        let instrument_id = cmd.instrument_id;
1520        self.option_greeks_subs.insert(instrument_id);
1521
1522        let mut should_subscribe = false;
1523        self.ticker_subs.rcu(|m| {
1524            let entry = m.entry(instrument_id).or_default();
1525            should_subscribe = entry.is_empty();
1526            entry.insert("option_greeks");
1527        });
1528
1529        if should_subscribe {
1530            let product_type = self
1531                .get_product_type_for_instrument(instrument_id)
1532                .unwrap_or(BybitProductType::Option);
1533
1534            let ws = self
1535                .get_ws_client_for_product(product_type)
1536                .context("no WebSocket client for product type")?
1537                .clone();
1538
1539            self.spawn_ws(
1540                async move {
1541                    ws.subscribe_ticker(instrument_id)
1542                        .await
1543                        .context("ticker subscription for option greeks")
1544                },
1545                "option greeks subscription",
1546            );
1547        }
1548        Ok(())
1549    }
1550
1551    fn unsubscribe_option_greeks(&mut self, cmd: &UnsubscribeOptionGreeks) -> anyhow::Result<()> {
1552        let instrument_id = cmd.instrument_id;
1553        self.option_greeks_subs.remove(&instrument_id);
1554
1555        let mut should_unsubscribe = false;
1556        self.ticker_subs.rcu(|m| {
1557            if let Some(entry) = m.get_mut(&instrument_id) {
1558                entry.remove("option_greeks");
1559                if entry.is_empty() {
1560                    m.remove(&instrument_id);
1561                    should_unsubscribe = true;
1562                } else {
1563                    should_unsubscribe = false;
1564                }
1565            } else {
1566                should_unsubscribe = false;
1567            }
1568        });
1569
1570        if should_unsubscribe {
1571            let product_type = self
1572                .get_product_type_for_instrument(instrument_id)
1573                .unwrap_or(BybitProductType::Option);
1574
1575            let ws = self
1576                .get_ws_client_for_product(product_type)
1577                .context("no WebSocket client for product type")?
1578                .clone();
1579
1580            self.spawn_ws(
1581                async move {
1582                    ws.unsubscribe_ticker(instrument_id)
1583                        .await
1584                        .context("ticker unsubscribe for option greeks")
1585                },
1586                "option greeks unsubscribe",
1587            );
1588        }
1589        Ok(())
1590    }
1591
1592    fn subscribe_instruments(&mut self, cmd: SubscribeInstruments) -> anyhow::Result<()> {
1593        log::debug!(
1594            "subscribe_instruments: {venue} (definition updates detected via periodic instrument info polling)",
1595            venue = cmd.venue,
1596        );
1597        self.subscribe_all_instruments
1598            .store(true, Ordering::Relaxed);
1599        Ok(())
1600    }
1601
1602    fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
1603        log::debug!(
1604            "subscribe_instrument: {id} (definition updates detected via periodic instrument info polling)",
1605            id = cmd.instrument_id,
1606        );
1607        self.instrument_subs.insert(cmd.instrument_id);
1608        Ok(())
1609    }
1610
1611    fn unsubscribe_instruments(&mut self, cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
1612        log::debug!("unsubscribe_instruments: {venue}", venue = cmd.venue);
1613        self.subscribe_all_instruments
1614            .store(false, Ordering::Relaxed);
1615        Ok(())
1616    }
1617
1618    fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1619        log::debug!("unsubscribe_instrument: {id}", id = cmd.instrument_id);
1620        self.instrument_subs.remove(&cmd.instrument_id);
1621        Ok(())
1622    }
1623
1624    fn subscribe_instrument_status(
1625        &mut self,
1626        cmd: SubscribeInstrumentStatus,
1627    ) -> anyhow::Result<()> {
1628        log::debug!(
1629            "subscribe_instrument_status: {id} (status changes detected via periodic instrument info polling)",
1630            id = cmd.instrument_id,
1631        );
1632        self.instrument_status_subs.insert(cmd.instrument_id);
1633
1634        if let Some(action) = self.status_cache.load().get(&cmd.instrument_id).copied() {
1635            let ts = self.clock.get_time_ns();
1636            emit_status(&self.data_sender, cmd.instrument_id, action, ts, ts);
1637        }
1638
1639        Ok(())
1640    }
1641
1642    fn unsubscribe_instrument_status(
1643        &mut self,
1644        cmd: &UnsubscribeInstrumentStatus,
1645    ) -> anyhow::Result<()> {
1646        log::debug!(
1647            "unsubscribe_instrument_status: {id}",
1648            id = cmd.instrument_id,
1649        );
1650        self.instrument_status_subs.remove(&cmd.instrument_id);
1651        Ok(())
1652    }
1653
1654    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1655        let http = self.http_client.clone();
1656        let sender = self.data_sender.clone();
1657        let instruments_cache = self.instruments.clone();
1658        let request_id = request.request_id;
1659        let client_id = request.client_id.unwrap_or(self.client_id);
1660        let venue = self.venue();
1661        let start = request.start;
1662        let end = request.end;
1663        let params = request.params;
1664        let clock = self.clock;
1665        let start_nanos = datetime_to_unix_nanos(start);
1666        let end_nanos = datetime_to_unix_nanos(end);
1667        let product_types = if self.config.product_types.is_empty() {
1668            vec![BybitProductType::Linear]
1669        } else {
1670            self.config.product_types.clone()
1671        };
1672
1673        self.spawn_command(async move {
1674            let mut all_instruments = Vec::new();
1675
1676            for product_type in product_types {
1677                match http.request_instruments(product_type, None, None).await {
1678                    Ok(instruments) => {
1679                        for instrument in instruments {
1680                            upsert_instrument(&instruments_cache, instrument.clone());
1681                            all_instruments.push(instrument);
1682                        }
1683                    }
1684                    Err(e) => {
1685                        log::error!("Failed to fetch instruments for {product_type:?}: {e:?}");
1686                    }
1687                }
1688            }
1689
1690            let response = DataResponse::Instruments(InstrumentsResponse::new(
1691                request_id,
1692                client_id,
1693                venue,
1694                all_instruments,
1695                start_nanos,
1696                end_nanos,
1697                clock.get_time_ns(),
1698                params,
1699            ));
1700
1701            if let Err(e) = sender.send(DataEvent::Response(response)) {
1702                log::error!("Failed to send instruments response: {e}");
1703            }
1704        });
1705
1706        Ok(())
1707    }
1708
1709    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1710        let http = self.http_client.clone();
1711        let sender = self.data_sender.clone();
1712        let instruments = self.instruments.clone();
1713        let instrument_id = request.instrument_id;
1714        let request_id = request.request_id;
1715        let client_id = request.client_id.unwrap_or(self.client_id);
1716        let start = request.start;
1717        let end = request.end;
1718        let params = request.params;
1719        let clock = self.clock;
1720        let start_nanos = datetime_to_unix_nanos(start);
1721        let end_nanos = datetime_to_unix_nanos(end);
1722
1723        let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
1724            .unwrap_or(BybitProductType::Linear);
1725        let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str()).to_string();
1726
1727        self.spawn_command(async move {
1728            match http
1729                .request_instruments(product_type, Some(raw_symbol), None)
1730                .await
1731                .context("fetch instrument from API")
1732            {
1733                Ok(fetched) => {
1734                    if let Some(instrument) = fetched.into_iter().find(|i| i.id() == instrument_id)
1735                    {
1736                        upsert_instrument(&instruments, instrument.clone());
1737
1738                        let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1739                            request_id,
1740                            client_id,
1741                            instrument.id(),
1742                            instrument,
1743                            start_nanos,
1744                            end_nanos,
1745                            clock.get_time_ns(),
1746                            params,
1747                        )));
1748
1749                        if let Err(e) = sender.send(DataEvent::Response(response)) {
1750                            log::error!("Failed to send instrument response: {e}");
1751                        }
1752                    } else {
1753                        log::error!("Instrument not found: {instrument_id}");
1754                    }
1755                }
1756                Err(e) => log::error!("Instrument request failed: {e:?}"),
1757            }
1758        });
1759
1760        Ok(())
1761    }
1762
1763    fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1764        let http = self.http_client.clone();
1765        let sender = self.data_sender.clone();
1766        let instrument_id = request.instrument_id;
1767        let depth = request.depth.map(|n| n.get() as u32);
1768        let request_id = request.request_id;
1769        let client_id = request.client_id.unwrap_or(self.client_id);
1770        let params = request.params;
1771        let clock = self.clock;
1772
1773        let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
1774            .unwrap_or(BybitProductType::Linear);
1775
1776        self.spawn_command(async move {
1777            match http
1778                .request_orderbook_snapshot(product_type, instrument_id, depth)
1779                .await
1780                .context("failed to request book snapshot from Bybit")
1781            {
1782                Ok(deltas) => {
1783                    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1784                    if let Err(e) = book.apply_deltas(&deltas) {
1785                        log::error!("Failed to apply book deltas for {instrument_id}: {e}");
1786                        return;
1787                    }
1788
1789                    let response = DataResponse::Book(BookResponse::new(
1790                        request_id,
1791                        client_id,
1792                        instrument_id,
1793                        book,
1794                        None,
1795                        None,
1796                        clock.get_time_ns(),
1797                        params,
1798                    ));
1799
1800                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1801                        log::error!("Failed to send book snapshot response: {e}");
1802                    }
1803                }
1804                Err(e) => log::error!("Book snapshot request failed for {instrument_id}: {e:?}"),
1805            }
1806        });
1807
1808        Ok(())
1809    }
1810
1811    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1812        let http = self.http_client.clone();
1813        let sender = self.data_sender.clone();
1814        let instrument_id = request.instrument_id;
1815        let start = request.start;
1816        let end = request.end;
1817        let limit = request.limit.map(|n| n.get() as u32);
1818        let request_id = request.request_id;
1819        let client_id = request.client_id.unwrap_or(self.client_id);
1820        let params = request.params;
1821        let clock = self.clock;
1822        let start_nanos = datetime_to_unix_nanos(start);
1823        let end_nanos = datetime_to_unix_nanos(end);
1824
1825        let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
1826            .unwrap_or(BybitProductType::Linear);
1827
1828        self.spawn_command(async move {
1829            match http
1830                .request_trades(product_type, instrument_id, limit)
1831                .await
1832                .context("failed to request trades from Bybit")
1833            {
1834                Ok(trades) => {
1835                    let response = DataResponse::Trades(TradesResponse::new(
1836                        request_id,
1837                        client_id,
1838                        instrument_id,
1839                        trades,
1840                        start_nanos,
1841                        end_nanos,
1842                        clock.get_time_ns(),
1843                        params,
1844                    ));
1845
1846                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1847                        log::error!("Failed to send trades response: {e}");
1848                    }
1849                }
1850                Err(e) => log::error!("Trade request failed: {e:?}"),
1851            }
1852        });
1853
1854        Ok(())
1855    }
1856
1857    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1858        let http = self.http_client.clone();
1859        let sender = self.data_sender.clone();
1860        let bar_type = request.bar_type;
1861        let start = request.start;
1862        let end = request.end;
1863        let limit = request.limit.map(|n| n.get() as u32);
1864        let request_id = request.request_id;
1865        let client_id = request.client_id.unwrap_or(self.client_id);
1866        let params = request.params;
1867        let clock = self.clock;
1868        let start_nanos = datetime_to_unix_nanos(start);
1869        let end_nanos = datetime_to_unix_nanos(end);
1870
1871        let instrument_id = bar_type.instrument_id();
1872        let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
1873            .unwrap_or(BybitProductType::Linear);
1874
1875        self.spawn_command(async move {
1876            match http
1877                .request_bars(product_type, bar_type, start, end, limit, true)
1878                .await
1879                .context("failed to request bars from Bybit")
1880            {
1881                Ok(bars) => {
1882                    let response = DataResponse::Bars(BarsResponse::new(
1883                        request_id,
1884                        client_id,
1885                        bar_type,
1886                        bars,
1887                        start_nanos,
1888                        end_nanos,
1889                        clock.get_time_ns(),
1890                        params,
1891                    ));
1892
1893                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1894                        log::error!("Failed to send bars response: {e}");
1895                    }
1896                }
1897                Err(e) => log::error!("Bar request failed: {e:?}"),
1898            }
1899        });
1900
1901        Ok(())
1902    }
1903
1904    fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1905        let http = self.http_client.clone();
1906        let sender = self.data_sender.clone();
1907        let instrument_id = request.instrument_id;
1908        let start = request.start;
1909        let end = request.end;
1910        let limit = request.limit.map(|n| n.get() as u32);
1911        let request_id = request.request_id;
1912        let client_id = request.client_id.unwrap_or(self.client_id);
1913        let params = request.params;
1914        let clock = self.clock;
1915        let start_nanos = datetime_to_unix_nanos(start);
1916        let end_nanos = datetime_to_unix_nanos(end);
1917
1918        let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
1919            .unwrap_or(BybitProductType::Linear);
1920
1921        if product_type == BybitProductType::Spot || product_type == BybitProductType::Option {
1922            anyhow::bail!("Funding rates not available for {product_type} instruments");
1923        }
1924
1925        self.spawn_command(async move {
1926            match http
1927                .request_funding_rates(product_type, instrument_id, start, end, limit)
1928                .await
1929                .context("failed to request funding rates from Bybit")
1930            {
1931                Ok(funding_rates) => {
1932                    let response = DataResponse::FundingRates(FundingRatesResponse::new(
1933                        request_id,
1934                        client_id,
1935                        instrument_id,
1936                        funding_rates,
1937                        start_nanos,
1938                        end_nanos,
1939                        clock.get_time_ns(),
1940                        params,
1941                    ));
1942
1943                    if let Err(e) = sender.send(DataEvent::Response(response)) {
1944                        log::error!("Failed to send funding rates response: {e}");
1945                    }
1946                }
1947                Err(e) => log::error!("Funding rates request failed for {instrument_id}: {e:?}"),
1948            }
1949        });
1950
1951        Ok(())
1952    }
1953
1954    fn request_option_chain_reference_price(
1955        &self,
1956        request: RequestOptionChainReferencePrice,
1957    ) -> anyhow::Result<()> {
1958        let series_id = request.series_id;
1959        let instrument_id = request.instrument_id;
1960        let http_client = self.http_client.clone();
1961        let sender = self.data_sender.clone();
1962        let request_id = request.request_id;
1963        let client_id = request.client_id.unwrap_or(self.client_id());
1964        let params = request.params;
1965        let clock = self.clock;
1966
1967        self.spawn_command(async move {
1968            let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str()).to_string();
1969            let query = crate::http::query::BybitTickersParams {
1970                category: BybitProductType::Option,
1971                symbol: Some(raw_symbol),
1972                base_coin: None,
1973                exp_date: None,
1974            };
1975            let price = match http_client.request_option_tickers_raw_with_params(&query).await {
1976                Ok(tickers) => tickers.into_iter().find_map(|ticker| {
1977                    let decimal = ticker.underlying_price.parse::<Decimal>().ok()?;
1978                    if decimal <= Decimal::ZERO {
1979                        return None;
1980                    }
1981
1982                    match Price::from_decimal(decimal) {
1983                        Ok(price) => Some(price),
1984                        Err(e) => {
1985                            log::warn!(
1986                                "Invalid Bybit option-chain reference price for {instrument_id}: {e}"
1987                            );
1988                            None
1989                        }
1990                    }
1991                }),
1992                Err(e) => {
1993                    log::error!(
1994                        "Option-chain reference price request failed for {series_id}: {e:?}"
1995                    );
1996                    None
1997                }
1998            };
1999            let response = DataResponse::OptionChainReferencePrice(
2000                OptionChainReferencePriceResponse::new(
2001                    request_id,
2002                    client_id,
2003                    series_id,
2004                    price,
2005                    clock.get_time_ns(),
2006                    params,
2007                ),
2008            );
2009
2010            if let Err(e) = sender.send(DataEvent::Response(response)) {
2011                log::error!("Failed to send option-chain reference price response: {e}");
2012            }
2013        });
2014
2015        Ok(())
2016    }
2017}
2018
2019#[cfg(test)]
2020mod tests {
2021    use std::{sync::Arc, time::Duration};
2022
2023    use ahash::{AHashMap, AHashSet};
2024    use nautilus_common::{
2025        cache::quote::QuoteCache,
2026        clients::DataClient,
2027        live::runner::set_data_event_sender,
2028        messages::{DataEvent, data::SubscribeBookDeltas},
2029        testing::wait_until_async,
2030    };
2031    use nautilus_core::{
2032        AtomicMap, AtomicSet, UUID4, UnixNanos,
2033        time::{AtomicTime, get_atomic_clock_realtime},
2034    };
2035    use nautilus_model::{
2036        data::{BarType, Data, QuoteTick},
2037        enums::{AggressorSide, BookAction, BookType},
2038        identifiers::InstrumentId,
2039        instruments::{Instrument, InstrumentAny},
2040        types::{Price, Quantity},
2041    };
2042    use rstest::rstest;
2043    use ustr::Ustr;
2044
2045    use super::{BybitDataClient, handle_ws_message, validate_orderbook_depth};
2046    use crate::{
2047        common::{
2048            consts::BYBIT_CLIENT_ID,
2049            enums::BybitProductType,
2050            parse::{parse_linear_instrument, parse_option_instrument},
2051            rate_limit::BYBIT_OPTION_SUBSCRIPTION_LIMIT,
2052            testing::load_test_json,
2053        },
2054        config::BybitDataClientConfig,
2055        http::models::{
2056            BybitFeeRate, BybitInstrumentLinearResponse, BybitInstrumentOptionResponse,
2057        },
2058        websocket::messages::{
2059            BybitWsMessage, BybitWsOrderbookDepthMsg, BybitWsTickerLinearMsg,
2060            BybitWsTickerOptionMsg, BybitWsTradeMsg,
2061        },
2062    };
2063
2064    fn sample_fee_rate(
2065        symbol: &str,
2066        taker: &str,
2067        maker: &str,
2068        base_coin: Option<&str>,
2069    ) -> BybitFeeRate {
2070        BybitFeeRate {
2071            symbol: Ustr::from(symbol),
2072            taker_fee_rate: taker.to_string(),
2073            maker_fee_rate: maker.to_string(),
2074            base_coin: base_coin.map(Ustr::from),
2075        }
2076    }
2077
2078    fn linear_instrument() -> InstrumentAny {
2079        let json = load_test_json("http_get_instruments_linear.json");
2080        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2081        let instrument = &response.result.list[0];
2082        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
2083        let ts = UnixNanos::new(1_700_000_000_000_000_000);
2084        parse_linear_instrument(instrument, &fee_rate, ts, ts).unwrap()
2085    }
2086
2087    fn option_instrument() -> InstrumentAny {
2088        let json = load_test_json("http_get_instruments_option.json");
2089        let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
2090        let instrument = &response.result.list[0];
2091        let ts = UnixNanos::new(1_700_000_000_000_000_000);
2092        parse_option_instrument(instrument, None, ts, ts).unwrap()
2093    }
2094
2095    fn build_instruments(instruments: &[InstrumentAny]) -> AHashMap<Ustr, InstrumentAny> {
2096        let mut map = AHashMap::new();
2097        for inst in instruments {
2098            map.insert(inst.id().symbol.inner(), inst.clone());
2099        }
2100        map
2101    }
2102
2103    #[expect(clippy::type_complexity)]
2104    fn empty_subs() -> (
2105        Arc<AtomicSet<InstrumentId>>,
2106        Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
2107        Arc<AtomicSet<InstrumentId>>,
2108        Arc<AtomicMap<InstrumentId, u32>>,
2109        Arc<AtomicSet<InstrumentId>>,
2110        Arc<AtomicMap<String, BarType>>,
2111    ) {
2112        (
2113            Arc::new(AtomicSet::new()),
2114            Arc::new(AtomicMap::new()),
2115            Arc::new(AtomicSet::new()),
2116            Arc::new(AtomicMap::new()),
2117            Arc::new(AtomicSet::new()),
2118            Arc::new(AtomicMap::new()),
2119        )
2120    }
2121
2122    #[tokio::test]
2123    async fn test_failed_book_subscription_releases_registration() {
2124        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2125        set_data_event_sender(tx);
2126        let config = BybitDataClientConfig {
2127            product_types: vec![BybitProductType::Option],
2128            ..Default::default()
2129        };
2130        let mut client = BybitDataClient::new(*BYBIT_CLIENT_ID, config).unwrap();
2131        let ws = client.ws_clients[0].clone();
2132        let topics = (0..BYBIT_OPTION_SUBSCRIPTION_LIMIT)
2133            .map(|index| format!("tickers.OPTION-{index}"))
2134            .collect();
2135
2136        // The disconnected command channel rejects the send after registering these topics
2137        ws.subscribe(topics).await.unwrap_err();
2138        let instrument_id = InstrumentId::from("ETH-26JUN26-16000-P-OPTION.BYBIT");
2139        let expected_error = format!(
2140            "Client error: Option WebSocket subscription limit is {BYBIT_OPTION_SUBSCRIPTION_LIMIT} arguments per connection, requested {}",
2141            BYBIT_OPTION_SUBSCRIPTION_LIMIT + 1,
2142        );
2143        assert_eq!(
2144            ws.subscribe_orderbook(instrument_id, 50)
2145                .await
2146                .unwrap_err()
2147                .to_string(),
2148            expected_error,
2149        );
2150        client
2151            .instruments
2152            .insert(instrument_id, option_instrument());
2153        let command = SubscribeBookDeltas::new(
2154            instrument_id,
2155            BookType::L2_MBP,
2156            Some(*BYBIT_CLIENT_ID),
2157            None,
2158            UUID4::new(),
2159            UnixNanos::default(),
2160            None,
2161            false,
2162            None,
2163            None,
2164        );
2165
2166        for _ in 0..2 {
2167            client.subscribe_book_deltas(command.clone()).unwrap();
2168            assert_eq!(client.book_depths.load().get(&instrument_id), Some(&50));
2169            wait_until_async(
2170                || async { !client.book_depths.contains_key(&instrument_id) },
2171                Duration::from_secs(2),
2172            )
2173            .await;
2174            assert!(!client.book_depths.contains_key(&instrument_id));
2175            assert_eq!(
2176                ws.subscribe_orderbook(instrument_id, 50)
2177                    .await
2178                    .unwrap_err()
2179                    .to_string(),
2180                expected_error,
2181            );
2182        }
2183    }
2184
2185    #[tokio::test]
2186    async fn test_failed_book_subscription_releases_transport_reference() {
2187        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2188        set_data_event_sender(tx);
2189        let mut client =
2190            BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
2191        let ws = client.ws_clients[0].clone();
2192        let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2193        client
2194            .subscribe_book_deltas(SubscribeBookDeltas::new(
2195                instrument_id,
2196                BookType::L2_MBP,
2197                Some(*BYBIT_CLIENT_ID),
2198                None,
2199                UUID4::new(),
2200                UnixNanos::default(),
2201                None,
2202                false,
2203                None,
2204                None,
2205            ))
2206            .unwrap();
2207        wait_until_async(
2208            || async { !client.book_depths.contains_key(&instrument_id) },
2209            Duration::from_secs(2),
2210        )
2211        .await;
2212
2213        let error = ws.subscribe_orderbook(instrument_id, 50).await.unwrap_err();
2214
2215        assert_eq!(
2216            error.to_string(),
2217            "WebSocket send error: Failed to send subscribe command: channel closed",
2218        );
2219        assert!(!client.book_depths.contains_key(&instrument_id));
2220    }
2221
2222    #[rstest]
2223    fn test_validate_orderbook_depth_accepts_1000() {
2224        assert!(validate_orderbook_depth(1000).is_ok());
2225    }
2226
2227    #[rstest]
2228    fn test_validate_orderbook_depth_rejects_500() {
2229        let e = validate_orderbook_depth(500).unwrap_err();
2230
2231        assert_eq!(
2232            e.to_string(),
2233            "invalid depth 500; valid values are [1, 50, 200, 1000]"
2234        );
2235    }
2236
2237    #[rstest]
2238    fn test_handle_trade_message_emits_trade_tick() {
2239        let instrument = linear_instrument();
2240        let instruments = build_instruments(std::slice::from_ref(&instrument));
2241        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2242            empty_subs();
2243        trade_subs.insert(instrument.id());
2244        let mut quote_cache = QuoteCache::new();
2245        let mut funding_cache = AHashMap::new();
2246        let clock = get_atomic_clock_realtime();
2247
2248        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2249
2250        let json = load_test_json("ws_public_trade.json");
2251        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2252        let ws_msg = BybitWsMessage::Trade(msg);
2253
2254        handle_ws_message(
2255            &ws_msg,
2256            &tx.into(),
2257            &instruments,
2258            Some(BybitProductType::Linear),
2259            &trade_subs,
2260            &ticker_subs,
2261            &quote_subs,
2262            &book_depths,
2263            &greeks_subs,
2264            &bar_types,
2265            &mut quote_cache,
2266            &mut funding_cache,
2267            clock,
2268        );
2269
2270        let event = rx.try_recv().unwrap();
2271        match event {
2272            DataEvent::Data(Data::Trade(tick)) => {
2273                assert_eq!(tick.instrument_id, instrument.id());
2274                assert_eq!(tick.price, instrument.make_price(27451.00));
2275                assert_eq!(tick.size, instrument.make_qty(0.010, None));
2276                assert_eq!(tick.aggressor_side, AggressorSide::Buy);
2277            }
2278            other => panic!("Expected Trade data event, found {other:?}"),
2279        }
2280    }
2281
2282    #[rstest]
2283    fn test_handle_trade_message_unknown_symbol_no_event() {
2284        let instruments = AHashMap::new();
2285        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2286            empty_subs();
2287        let mut quote_cache = QuoteCache::new();
2288        let mut funding_cache = AHashMap::new();
2289        let clock = get_atomic_clock_realtime();
2290
2291        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2292
2293        let json = load_test_json("ws_public_trade.json");
2294        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2295        let ws_msg = BybitWsMessage::Trade(msg);
2296
2297        handle_ws_message(
2298            &ws_msg,
2299            &tx.into(),
2300            &instruments,
2301            Some(BybitProductType::Linear),
2302            &trade_subs,
2303            &ticker_subs,
2304            &quote_subs,
2305            &book_depths,
2306            &greeks_subs,
2307            &bar_types,
2308            &mut quote_cache,
2309            &mut funding_cache,
2310            clock,
2311        );
2312
2313        rx.try_recv().unwrap_err();
2314    }
2315
2316    #[rstest]
2317    #[case::shared_depth_one("orderbook.1.BTCUSDT", 1, "snapshot", true, true)]
2318    #[case::depth_one_with_deeper_book("orderbook.1.BTCUSDT", 50, "snapshot", false, true)]
2319    #[case::deeper_snapshot("orderbook.50.BTCUSDT", 50, "snapshot", true, false)]
2320    #[case::deeper_deletion("orderbook.50.BTCUSDT", 50, "delta", true, false)]
2321    #[case::unsubscribed_depth("orderbook.200.BTCUSDT", 50, "delta", false, false)]
2322    #[case::invalid_topic("orderbook.invalid.BTCUSDT", 1, "snapshot", false, false)]
2323    #[case::wrong_symbol("orderbook.1.ETHUSDT", 1, "snapshot", false, false)]
2324    fn test_handle_orderbook_routes_by_depth(
2325        #[case] topic: &str,
2326        #[case] book_depth: u32,
2327        #[case] msg_type: &str,
2328        #[case] expect_book: bool,
2329        #[case] expect_quote: bool,
2330    ) {
2331        let instrument = linear_instrument();
2332        let instrument_id = instrument.id();
2333        let instruments = build_instruments(&[instrument]);
2334        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2335            empty_subs();
2336        book_depths.insert(instrument_id, book_depth);
2337        quote_subs.insert(instrument_id);
2338        let previous = QuoteTick::new(
2339            instrument_id,
2340            Price::from("27000.00"),
2341            Price::from("27001.00"),
2342            Quantity::from("2.000"),
2343            Quantity::from("3.000"),
2344            UnixNanos::new(1),
2345            UnixNanos::new(2),
2346        );
2347        let mut quote_cache = QuoteCache::new();
2348        quote_cache.insert(instrument_id, previous);
2349        let mut funding_cache = AHashMap::new();
2350        let ts_init = UnixNanos::new(1_709_891_680_000_000_000);
2351        let clock = AtomicTime::new(false, ts_init);
2352        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2353        let fixture = if msg_type == "snapshot" {
2354            "ws_orderbook_snapshot.json"
2355        } else {
2356            "ws_orderbook_delta.json"
2357        };
2358        let mut msg: BybitWsOrderbookDepthMsg =
2359            serde_json::from_str(&load_test_json(fixture)).unwrap();
2360        msg.topic = topic.into();
2361
2362        handle_ws_message(
2363            &BybitWsMessage::Orderbook(msg),
2364            &tx.into(),
2365            &instruments,
2366            Some(BybitProductType::Linear),
2367            &trade_subs,
2368            &ticker_subs,
2369            &quote_subs,
2370            &book_depths,
2371            &greeks_subs,
2372            &bar_types,
2373            &mut quote_cache,
2374            &mut funding_cache,
2375            &clock,
2376        );
2377
2378        if expect_book {
2379            let DataEvent::Data(Data::BookDeltas(deltas)) = rx.try_recv().unwrap() else {
2380                panic!("Expected book deltas");
2381            };
2382            assert_eq!(deltas.instrument_id, instrument_id);
2383            assert_eq!(
2384                deltas.deltas[0].action == BookAction::Clear,
2385                msg_type == "snapshot"
2386            );
2387
2388            if msg_type == "delta" {
2389                assert_eq!(deltas.deltas.len(), 2);
2390                assert_eq!(deltas.deltas[1].action, BookAction::Delete);
2391                assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.000"));
2392            }
2393        }
2394
2395        if expect_quote {
2396            let expected = QuoteTick::new(
2397                instrument_id,
2398                Price::from("27450.00"),
2399                Price::from("27451.00"),
2400                Quantity::from("0.500"),
2401                Quantity::from("0.750"),
2402                UnixNanos::new(1_709_891_679_000_000_000),
2403                ts_init,
2404            );
2405            let DataEvent::Data(Data::Quote(quote)) = rx.try_recv().unwrap() else {
2406                panic!("Expected quote");
2407            };
2408            assert_eq!(quote, expected);
2409            assert_eq!(quote_cache.get(&instrument_id), Some(&expected));
2410        } else {
2411            assert_eq!(quote_cache.get(&instrument_id), Some(&previous));
2412        }
2413        rx.try_recv().unwrap_err();
2414    }
2415
2416    #[rstest]
2417    fn test_handle_orderbook_message_no_sub_no_event() {
2418        let instrument = linear_instrument();
2419        let instruments = build_instruments(&[instrument]);
2420        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2421            empty_subs();
2422        let mut quote_cache = QuoteCache::new();
2423        let mut funding_cache = AHashMap::new();
2424        let clock = get_atomic_clock_realtime();
2425
2426        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2427
2428        let json = load_test_json("ws_orderbook_snapshot.json");
2429        let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
2430        let ws_msg = BybitWsMessage::Orderbook(msg);
2431
2432        handle_ws_message(
2433            &ws_msg,
2434            &tx.into(),
2435            &instruments,
2436            Some(BybitProductType::Linear),
2437            &trade_subs,
2438            &ticker_subs,
2439            &quote_subs,
2440            &book_depths,
2441            &greeks_subs,
2442            &bar_types,
2443            &mut quote_cache,
2444            &mut funding_cache,
2445            clock,
2446        );
2447
2448        rx.try_recv().unwrap_err();
2449    }
2450
2451    #[rstest]
2452    fn test_handle_ticker_linear_does_not_emit_quote() {
2453        let instrument = linear_instrument();
2454        let instrument_id = instrument.id();
2455        let instruments = build_instruments(&[instrument]);
2456        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2457            empty_subs();
2458
2459        let mut subs = AHashSet::new();
2460        subs.insert("quotes");
2461        ticker_subs.insert(instrument_id, subs);
2462
2463        let mut quote_cache = QuoteCache::new();
2464        let mut funding_cache = AHashMap::new();
2465        let clock = get_atomic_clock_realtime();
2466
2467        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2468
2469        let json = load_test_json("ws_ticker_linear.json");
2470        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
2471        let ws_msg = BybitWsMessage::TickerLinear(msg);
2472
2473        handle_ws_message(
2474            &ws_msg,
2475            &tx.into(),
2476            &instruments,
2477            Some(BybitProductType::Linear),
2478            &trade_subs,
2479            &ticker_subs,
2480            &quote_subs,
2481            &book_depths,
2482            &greeks_subs,
2483            &bar_types,
2484            &mut quote_cache,
2485            &mut funding_cache,
2486            clock,
2487        );
2488
2489        rx.try_recv().unwrap_err();
2490        assert!(quote_cache.is_empty());
2491    }
2492
2493    #[rstest]
2494    fn test_handle_ticker_linear_funding_dedup() {
2495        let instrument = linear_instrument();
2496        let instrument_id = instrument.id();
2497        let instruments = build_instruments(&[instrument]);
2498        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2499            empty_subs();
2500
2501        let mut subs = AHashSet::new();
2502        subs.insert("funding");
2503        ticker_subs.insert(instrument_id, subs);
2504
2505        let mut quote_cache = QuoteCache::new();
2506        let mut funding_cache = AHashMap::new();
2507        let clock = get_atomic_clock_realtime();
2508
2509        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2510
2511        let json = load_test_json("ws_ticker_linear.json");
2512        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
2513        let ws_msg = BybitWsMessage::TickerLinear(msg.clone());
2514
2515        handle_ws_message(
2516            &ws_msg,
2517            &tx.clone().into(),
2518            &instruments,
2519            Some(BybitProductType::Linear),
2520            &trade_subs,
2521            &ticker_subs,
2522            &quote_subs,
2523            &book_depths,
2524            &greeks_subs,
2525            &bar_types,
2526            &mut quote_cache,
2527            &mut funding_cache,
2528            clock,
2529        );
2530
2531        let event = rx.try_recv().unwrap();
2532        assert!(matches!(event, DataEvent::FundingRate(_)));
2533
2534        // Send same message again, funding unchanged so should be deduped
2535        let ws_msg2 = BybitWsMessage::TickerLinear(msg);
2536        handle_ws_message(
2537            &ws_msg2,
2538            &tx.into(),
2539            &instruments,
2540            Some(BybitProductType::Linear),
2541            &trade_subs,
2542            &ticker_subs,
2543            &quote_subs,
2544            &book_depths,
2545            &greeks_subs,
2546            &bar_types,
2547            &mut quote_cache,
2548            &mut funding_cache,
2549            clock,
2550        );
2551
2552        rx.try_recv().unwrap_err();
2553    }
2554
2555    #[rstest]
2556    fn test_handle_ticker_linear_mark_and_index_prices() {
2557        let instrument = linear_instrument();
2558        let instrument_id = instrument.id();
2559        let instruments = build_instruments(&[instrument]);
2560        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2561            empty_subs();
2562
2563        let mut subs = AHashSet::new();
2564        subs.insert("mark_prices");
2565        subs.insert("index_prices");
2566        ticker_subs.insert(instrument_id, subs);
2567
2568        let mut quote_cache = QuoteCache::new();
2569        let mut funding_cache = AHashMap::new();
2570        let clock = get_atomic_clock_realtime();
2571
2572        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2573
2574        let json = load_test_json("ws_ticker_linear.json");
2575        let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
2576        let ws_msg = BybitWsMessage::TickerLinear(msg);
2577
2578        handle_ws_message(
2579            &ws_msg,
2580            &tx.into(),
2581            &instruments,
2582            Some(BybitProductType::Linear),
2583            &trade_subs,
2584            &ticker_subs,
2585            &quote_subs,
2586            &book_depths,
2587            &greeks_subs,
2588            &bar_types,
2589            &mut quote_cache,
2590            &mut funding_cache,
2591            clock,
2592        );
2593
2594        let event1 = rx.try_recv().unwrap();
2595        assert!(matches!(event1, DataEvent::Data(Data::MarkPrice(_))));
2596
2597        let event2 = rx.try_recv().unwrap();
2598        assert!(matches!(event2, DataEvent::Data(Data::IndexPrice(_))));
2599    }
2600
2601    #[rstest]
2602    fn test_handle_reconnected_clears_caches() {
2603        let instruments = AHashMap::new();
2604        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2605            empty_subs();
2606        let mut quote_cache = QuoteCache::new();
2607        let mut funding_cache = AHashMap::new();
2608        let clock = get_atomic_clock_realtime();
2609
2610        let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2611        quote_cache.insert(
2612            instrument_id,
2613            QuoteTick::new(
2614                instrument_id,
2615                Price::from("100.00"),
2616                Price::from("101.00"),
2617                Quantity::from("1.0"),
2618                Quantity::from("1.0"),
2619                UnixNanos::default(),
2620                UnixNanos::default(),
2621            ),
2622        );
2623        funding_cache.insert(
2624            Ustr::from("BTCUSDT"),
2625            (
2626                Some("-0.001".to_string()),
2627                Some("1000".to_string()),
2628                Some("8".to_string()),
2629            ),
2630        );
2631
2632        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2633
2634        handle_ws_message(
2635            &BybitWsMessage::Reconnected,
2636            &tx.into(),
2637            &instruments,
2638            None,
2639            &trade_subs,
2640            &ticker_subs,
2641            &quote_subs,
2642            &book_depths,
2643            &greeks_subs,
2644            &bar_types,
2645            &mut quote_cache,
2646            &mut funding_cache,
2647            clock,
2648        );
2649
2650        assert!(quote_cache.is_empty());
2651        assert!(funding_cache.is_empty());
2652    }
2653
2654    #[rstest]
2655    fn test_handle_ticker_option_greeks() {
2656        // Use the option instrument but key it by the ticker fixture symbol
2657        // (fixture instrument is ETH-26JUN26-16000-P, ticker fixture is BTC-6JAN23-17500-C)
2658        let instrument = option_instrument();
2659        let instrument_id = instrument.id();
2660
2661        // Key the instrument by the fixture ticker symbol with OPTION suffix
2662        let ticker_key = Ustr::from("BTC-6JAN23-17500-C-OPTION");
2663        let mut instruments = AHashMap::new();
2664        instruments.insert(ticker_key, instrument);
2665
2666        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2667            empty_subs();
2668        greeks_subs.insert(instrument_id);
2669
2670        let mut quote_cache = QuoteCache::new();
2671        let mut funding_cache = AHashMap::new();
2672        let clock = get_atomic_clock_realtime();
2673
2674        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2675
2676        let json = load_test_json("ws_ticker_option.json");
2677        let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
2678        let ws_msg = BybitWsMessage::TickerOption(msg);
2679
2680        handle_ws_message(
2681            &ws_msg,
2682            &tx.into(),
2683            &instruments,
2684            Some(BybitProductType::Option),
2685            &trade_subs,
2686            &ticker_subs,
2687            &quote_subs,
2688            &book_depths,
2689            &greeks_subs,
2690            &bar_types,
2691            &mut quote_cache,
2692            &mut funding_cache,
2693            clock,
2694        );
2695
2696        let event = rx.try_recv().unwrap();
2697        assert!(matches!(event, DataEvent::OptionGreeks(_)));
2698    }
2699
2700    #[rstest]
2701    fn test_handle_execution_message_ignored_by_data() {
2702        let instruments = AHashMap::new();
2703        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2704            empty_subs();
2705        let mut quote_cache = QuoteCache::new();
2706        let mut funding_cache = AHashMap::new();
2707        let clock = get_atomic_clock_realtime();
2708
2709        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2710
2711        let json = load_test_json("ws_account_order.json");
2712        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2713            serde_json::from_str(&json).unwrap();
2714        let ws_msg = BybitWsMessage::AccountOrder(msg);
2715
2716        handle_ws_message(
2717            &ws_msg,
2718            &tx.into(),
2719            &instruments,
2720            None,
2721            &trade_subs,
2722            &ticker_subs,
2723            &quote_subs,
2724            &book_depths,
2725            &greeks_subs,
2726            &bar_types,
2727            &mut quote_cache,
2728            &mut funding_cache,
2729            clock,
2730        );
2731
2732        rx.try_recv().unwrap_err();
2733    }
2734
2735    #[rstest]
2736    fn test_instrument_resolution_with_product_type() {
2737        let instrument = linear_instrument();
2738
2739        let mut map = AHashMap::new();
2740        map.insert(instrument.id().symbol.inner(), instrument.clone());
2741
2742        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2743            empty_subs();
2744        trade_subs.insert(instrument.id());
2745        let mut quote_cache = QuoteCache::new();
2746        let mut funding_cache = AHashMap::new();
2747        let clock = get_atomic_clock_realtime();
2748        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2749
2750        let json = load_test_json("ws_public_trade.json");
2751        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2752
2753        // With None product_type, raw symbol "BTCUSDT" does not match "BTCUSDT-LINEAR"
2754        handle_ws_message(
2755            &BybitWsMessage::Trade(msg.clone()),
2756            &tx.clone().into(),
2757            &map,
2758            None,
2759            &trade_subs,
2760            &ticker_subs,
2761            &quote_subs,
2762            &book_depths,
2763            &greeks_subs,
2764            &bar_types,
2765            &mut quote_cache,
2766            &mut funding_cache,
2767            clock,
2768        );
2769        rx.try_recv().unwrap_err();
2770
2771        // With product_type=Linear, "BTCUSDT" -> "BTCUSDT-LINEAR" matches
2772        handle_ws_message(
2773            &BybitWsMessage::Trade(msg),
2774            &tx.into(),
2775            &map,
2776            Some(BybitProductType::Linear),
2777            &trade_subs,
2778            &ticker_subs,
2779            &quote_subs,
2780            &book_depths,
2781            &greeks_subs,
2782            &bar_types,
2783            &mut quote_cache,
2784            &mut funding_cache,
2785            clock,
2786        );
2787
2788        let event = rx.try_recv().unwrap();
2789        assert!(matches!(event, DataEvent::Data(Data::Trade(_))));
2790    }
2791
2792    #[rstest]
2793    fn test_handle_trade_filters_by_subscription() {
2794        let instrument = linear_instrument();
2795        let instruments = build_instruments(std::slice::from_ref(&instrument));
2796        let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2797            empty_subs();
2798        let mut quote_cache = QuoteCache::new();
2799        let mut funding_cache = AHashMap::new();
2800        let clock = get_atomic_clock_realtime();
2801        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2802
2803        let json = load_test_json("ws_public_trade.json");
2804        let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2805
2806        // Without subscription, trade should be filtered out
2807        handle_ws_message(
2808            &BybitWsMessage::Trade(msg.clone()),
2809            &tx.clone().into(),
2810            &instruments,
2811            Some(BybitProductType::Linear),
2812            &trade_subs,
2813            &ticker_subs,
2814            &quote_subs,
2815            &book_depths,
2816            &greeks_subs,
2817            &bar_types,
2818            &mut quote_cache,
2819            &mut funding_cache,
2820            clock,
2821        );
2822        rx.try_recv().unwrap_err();
2823
2824        // With subscription, trade should be emitted
2825        trade_subs.insert(instrument.id());
2826        handle_ws_message(
2827            &BybitWsMessage::Trade(msg),
2828            &tx.into(),
2829            &instruments,
2830            Some(BybitProductType::Linear),
2831            &trade_subs,
2832            &ticker_subs,
2833            &quote_subs,
2834            &book_depths,
2835            &greeks_subs,
2836            &bar_types,
2837            &mut quote_cache,
2838            &mut funding_cache,
2839            clock,
2840        );
2841        let event = rx.try_recv().unwrap();
2842        assert!(matches!(event, DataEvent::Data(Data::Trade(_))));
2843    }
2844}