1use 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#[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 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 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; 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
444type 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 if self
816 .config
817 .instrument_poll_interval_secs
818 .is_some_and(|s| s > 0)
819 {
820 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 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 "e_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 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 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 "e_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 "e_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 "e_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 "e_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 "e_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 "e_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 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 "e_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 "e_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 "e_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 let instrument = option_instrument();
2659 let instrument_id = instrument.id();
2660
2661 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 "e_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 "e_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 handle_ws_message(
2755 &BybitWsMessage::Trade(msg.clone()),
2756 &tx.clone().into(),
2757 &map,
2758 None,
2759 &trade_subs,
2760 &ticker_subs,
2761 "e_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 handle_ws_message(
2773 &BybitWsMessage::Trade(msg),
2774 &tx.into(),
2775 &map,
2776 Some(BybitProductType::Linear),
2777 &trade_subs,
2778 &ticker_subs,
2779 "e_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 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 "e_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 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 "e_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}