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 dashmap::DashMap;
30use futures_util::{StreamExt, pin_mut};
31use nautilus_common::{
32 clients::DataClient,
33 live::{dst::time::Instant, 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 SubscribeCustomData, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
42 SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices,
43 SubscribeOptionGreeks, SubscribeQuotes, SubscribeTrades, TradesResponse,
44 UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeCustomData, UnsubscribeFundingRates,
45 UnsubscribeIndexPrices, UnsubscribeInstrument, UnsubscribeInstrumentStatus,
46 UnsubscribeInstruments, UnsubscribeMarkPrices, UnsubscribeOptionGreeks,
47 UnsubscribeQuotes, UnsubscribeTrades,
48 },
49 },
50};
51use nautilus_core::{
52 AtomicMap, AtomicSet, UnixNanos,
53 datetime::datetime_to_unix_nanos,
54 time::{AtomicTime, get_atomic_clock_realtime},
55};
56use nautilus_live::{
57 SocketControlFactory,
58 task::{TaskGroup, TaskGroupGuard, TaskSpawner},
59};
60use nautilus_model::{
61 data::{BarType, CustomData, Data, DataType, QuoteTick},
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 book::{
74 BookSequenceOutcome,
75 recovery::{reset_on_reconnect, spawn_subscription_task, start_recovery},
76 sync::BookSyncTracker,
77 },
78 common::{
79 consts::{
80 BYBIT_BOOK_DEPTHS, BYBIT_DEFAULT_ORDERBOOK_DEPTH, BYBIT_QUOTE_DEPTH, BYBIT_VENUE,
81 },
82 enums::BybitProductType,
83 instruments::diff_and_emit_instruments,
84 parse::{extract_raw_symbol, make_bybit_symbol},
85 status::{diff_and_emit_statuses, emit_status},
86 },
87 config::BybitDataClientConfig,
88 data_types::register_bybit_custom_data,
89 http::client::BybitHttpClient,
90 websocket::{
91 client::BybitWebSocketClient,
92 messages::{BybitWsMessage, BybitWsOrderbookDepthMsg},
93 parse::{
94 parse_kline_topic, parse_millis_i64, parse_orderbook_deltas, parse_orderbook_quote,
95 parse_orderbook_topic, parse_ticker_linear_funding, parse_ticker_linear_index_price,
96 parse_ticker_linear_mark_price, parse_ticker_option_greeks,
97 parse_ticker_option_index_price, parse_ticker_option_mark_price,
98 parse_ticker_option_quote, parse_ws_kline_bar, parse_ws_liquidation,
99 parse_ws_trade_tick,
100 },
101 },
102};
103
104#[derive(Debug)]
106pub struct BybitDataClient {
107 client_id: ClientId,
108 config: BybitDataClientConfig,
109 http_client: BybitHttpClient,
110 ws_clients: Vec<BybitWebSocketClient>,
111 session_established: AtomicBool,
112 cancellation_token: CancellationToken,
113 session_tasks: TaskGroup,
114 command_tasks: TaskGroup,
115 shutdown_errors: Vec<String>,
116 data_sender: EventSender<DataEvent>,
117 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
118 book_depths: Arc<AtomicMap<InstrumentId, u32>>,
119 book_sync: BookSyncTracker,
120 quote_subs: Arc<AtomicSet<InstrumentId>>,
121 last_quotes: Arc<DashMap<InstrumentId, QuoteTick>>,
122 ticker_subs: Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
123 trade_subs: Arc<AtomicSet<InstrumentId>>,
124 option_greeks_subs: Arc<AtomicSet<InstrumentId>>,
125 liquidation_subs: Arc<AtomicMap<InstrumentId, Arc<DataType>>>,
126 instrument_status_subs: Arc<AtomicSet<InstrumentId>>,
127 status_cache: Arc<AtomicMap<InstrumentId, MarketStatusAction>>,
128 instrument_subs: Arc<AtomicSet<InstrumentId>>,
129 subscribe_all_instruments: Arc<AtomicBool>,
130 clock: &'static AtomicTime,
131}
132
133impl BybitDataClient {
134 pub fn new(client_id: ClientId, config: BybitDataClientConfig) -> anyhow::Result<Self> {
140 let clock = get_atomic_clock_realtime();
141 let data_sender = get_data_event_sender();
142 let socket_factory = SocketControlFactory::new(client_id, Some(*BYBIT_VENUE));
143 let proxy_url = config
144 .proxy_url
145 .as_ref()
146 .map(|value| value.expose_secret().to_owned());
147
148 let http_client = if let (Some(api_key), Some(api_secret)) =
149 (config.api_key.clone(), config.api_secret.clone())
150 {
151 BybitHttpClient::with_credentials(
152 api_key.into_inner(),
153 api_secret.into_inner(),
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 } else {
163 BybitHttpClient::new(
164 Some(config.http_base_url()),
165 config.http_timeout_secs,
166 config.max_retries,
167 config.retry_delay_initial_ms,
168 config.retry_delay_max_ms,
169 config.recv_window_ms,
170 proxy_url.clone(),
171 )?
172 };
173
174 let product_types = if config.product_types.is_empty() {
176 vec![BybitProductType::Linear]
177 } else {
178 config.product_types.clone()
179 };
180
181 let ws_clients: Vec<BybitWebSocketClient> = product_types
182 .iter()
183 .map(|product_type| {
184 BybitWebSocketClient::new_public_with(
185 *product_type,
186 config.environment,
187 Some(config.ws_public_url_for(*product_type)),
188 config.heartbeat_interval_secs,
189 config.transport_backend,
190 proxy_url.clone(),
191 )
192 .with_socket_control(
193 socket_factory.control(format!("bybit-{}-data-streams", product_type.as_str())),
194 )
195 })
196 .collect();
197
198 let session_tasks = TaskGroup::new();
199 let command_tasks = TaskGroup::new();
200
201 Ok(Self {
202 client_id,
203 config,
204 http_client,
205 ws_clients,
206 session_established: AtomicBool::new(false),
207 cancellation_token: session_tasks.cancellation_token(),
208 session_tasks,
209 command_tasks,
210 shutdown_errors: Vec::new(),
211 data_sender,
212 instruments: Arc::new(AtomicMap::new()),
213 book_depths: Arc::new(AtomicMap::new()),
214 book_sync: BookSyncTracker::default(),
215 quote_subs: Arc::new(AtomicSet::new()),
216 last_quotes: Arc::new(DashMap::new()),
217 ticker_subs: Arc::new(AtomicMap::new()),
218 trade_subs: Arc::new(AtomicSet::new()),
219 option_greeks_subs: Arc::new(AtomicSet::new()),
220 liquidation_subs: Arc::new(AtomicMap::new()),
221 instrument_status_subs: Arc::new(AtomicSet::new()),
222 status_cache: Arc::new(AtomicMap::new()),
223 instrument_subs: Arc::new(AtomicSet::new()),
224 subscribe_all_instruments: Arc::new(AtomicBool::new(false)),
225 clock,
226 })
227 }
228
229 fn venue(&self) -> Venue {
230 *BYBIT_VENUE
231 }
232
233 fn get_ws_client_for_product(
234 &self,
235 product_type: BybitProductType,
236 ) -> Option<&BybitWebSocketClient> {
237 self.ws_clients
238 .iter()
239 .find(|ws| ws.product_type() == Some(product_type))
240 }
241
242 fn get_product_type_for_instrument(
243 &self,
244 instrument_id: InstrumentId,
245 ) -> Option<BybitProductType> {
246 let guard = self.instruments.load();
247 guard
248 .get(&instrument_id)
249 .and_then(|_| BybitProductType::from_suffix(instrument_id.symbol.as_str()))
250 }
251
252 fn spawn_command<F>(&self, future: F)
253 where
254 F: Future<Output = ()> + Send + 'static,
255 {
256 if let Err(e) = self.command_tasks.spawn(future) {
257 log::warn!("Skipping Bybit data command after shutdown began: {e}");
258 }
259 }
260
261 async fn finish_tasks(&self) -> anyhow::Result<()> {
262 let (session_result, command_result) = tokio::join!(
263 self.session_tasks
264 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
265 self.command_tasks
266 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
267 );
268 let mut errors = Vec::new();
269 if let Err(e) = session_result {
270 errors.push(format!("failed to finish Bybit data session tasks: {e}"));
271 }
272
273 if let Err(e) = command_result {
274 errors.push(format!("failed to finish Bybit data command tasks: {e}"));
275 }
276
277 if errors.is_empty() {
278 Ok(())
279 } else {
280 anyhow::bail!(errors.join("; "))
281 }
282 }
283
284 async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
285 if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
286 self.teardown_partial_connect().await?;
287 self.session_tasks
288 .start_generation()
289 .context("failed to start Bybit data session task generation")?;
290 self.command_tasks
291 .start_generation()
292 .context("failed to start Bybit data command task generation")?;
293 self.cancellation_token = self.session_tasks.cancellation_token();
294 }
295 Ok(())
296 }
297
298 fn spawn_ws<F>(&self, fut: F, context: &'static str)
299 where
300 F: Future<Output = anyhow::Result<()>> + Send + 'static,
301 {
302 let future = async move {
303 if let Err(e) = fut.await {
304 log::error!("{context}: {e:?}");
305 }
306 };
307
308 if let Err(e) = self.command_tasks.spawn(future) {
309 log::warn!("Skipping Bybit {context} after shutdown began: {e}");
310 }
311 }
312
313 fn spawn_instrument_polling(
314 &self,
315 product_types: &[BybitProductType],
316 poll_secs: u64,
317 ) -> anyhow::Result<()> {
318 let http = self.http_client.clone();
319 let sender = self.data_sender.clone();
320 let instruments = self.instruments.clone();
321 let status_cache = self.status_cache.clone();
322 let status_subs = self.instrument_status_subs.clone();
323 let instrument_subs = self.instrument_subs.clone();
324 let subscribe_all_instruments = self.subscribe_all_instruments.clone();
325 let cancel = self.cancellation_token.clone();
326 let clock = self.clock;
327 let product_types = product_types.to_vec();
328
329 let future = async move {
330 let mut interval = tokio::time::interval(Duration::from_secs(poll_secs));
331 interval.tick().await; loop {
334 tokio::select! {
335 _ = interval.tick() => {
336 let all_flag = subscribe_all_instruments.load(Ordering::Relaxed);
337 let want_instruments = all_flag || !instrument_subs.is_empty();
338 let want_statuses = !status_subs.is_empty();
339 if !want_instruments && !want_statuses {
340 continue;
341 }
342
343 let mut all_statuses = AHashMap::new();
344
345 if want_instruments {
346 let subs: Option<AHashSet<InstrumentId>> = if all_flag {
347 None
348 } else {
349 Some((**instrument_subs.load()).clone())
350 };
351 let mut inst_cache = (**instruments.load()).clone();
352
353 for &pt in &product_types {
354 match http.request_instruments_with_statuses(pt).await {
355 Ok((fetched, statuses)) => {
356 diff_and_emit_instruments(
357 &fetched, &mut inst_cache, subs.as_ref(), &sender,
358 );
359
360 for (id, action) in statuses {
361 if inst_cache.contains_key(&id) {
362 all_statuses.insert(id, action);
363 }
364 }
365 }
366 Err(e) => {
367 log::warn!("Bybit instrument poll failed for {pt:?}: {e}");
368 }
369 }
370 }
371
372 instruments.store(inst_cache);
373 } else {
374 for &pt in &product_types {
375 match http.request_instrument_statuses(pt).await {
376 Ok(new_statuses) => {
377 let inst_guard = instruments.load();
378 for (id, action) in new_statuses {
379 if inst_guard.contains_key(&id) {
380 all_statuses.insert(id, action);
381 }
382 }
383 }
384 Err(e) => {
385 log::warn!("Bybit instrument status poll failed for {pt:?}: {e}");
386 }
387 }
388 }
389 }
390
391 if want_statuses {
392 let ts = clock.get_time_ns();
393 let mut cache = (**status_cache.load()).clone();
394 let subs_guard = status_subs.load();
395 diff_and_emit_statuses(
396 &all_statuses, &mut cache, Some(&subs_guard), &sender, ts, ts,
397 );
398 status_cache.store(cache);
399 }
400 }
401 () = cancel.cancelled() => {
402 log::debug!("Bybit instrument polling task cancelled");
403 break;
404 }
405 }
406 }
407 };
408
409 self.session_tasks
410 .spawn(future)
411 .context("failed to register Bybit instrument polling task")?;
412 log::debug!("Instrument polling started: interval={poll_secs}s");
413 Ok(())
414 }
415
416 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
417 self.session_tasks.begin_shutdown();
418 self.command_tasks.begin_shutdown();
419 for ws_client in &self.ws_clients {
420 ws_client.begin_shutdown();
421 }
422
423 for ws_client in &mut self.ws_clients {
424 if let Err(e) = ws_client.close().await {
425 self.shutdown_errors.push(e.to_string());
426 }
427 }
428
429 if let Err(e) = self.finish_tasks().await {
430 self.shutdown_errors.push(e.to_string());
431 }
432 self.session_established.store(false, Ordering::Release);
433
434 if self.shutdown_errors.is_empty() {
435 Ok(())
436 } else {
437 let errors = std::mem::take(&mut self.shutdown_errors);
438 anyhow::bail!("Bybit data shutdown failed: {}", errors.join("; "))
439 }
440 }
441}
442
443fn send_data(sender: &EventSender<DataEvent>, data: Data) {
444 if let Err(e) = sender.send(DataEvent::Data(data)) {
445 log::error!("Failed to emit data event: {e}");
446 }
447}
448
449fn validate_orderbook_depth(depth: u32) -> anyhow::Result<()> {
450 if !BYBIT_BOOK_DEPTHS.contains(&depth) {
451 anyhow::bail!("invalid depth {depth}; valid values are {BYBIT_BOOK_DEPTHS:?}");
452 }
453
454 Ok(())
455}
456
457fn liquidation_instrument_id(data_type: &DataType, venue: Venue) -> anyhow::Result<InstrumentId> {
458 let instrument_id = data_type
459 .instrument_id()?
460 .context("BybitLiquidation requires `instrument_id` metadata")?;
461
462 if instrument_id.venue != venue {
463 anyhow::bail!("BybitLiquidation requires a {venue} instrument, received {instrument_id}");
464 }
465
466 Ok(instrument_id)
467}
468
469fn liquidation_product_type(instrument_id: InstrumentId) -> anyhow::Result<BybitProductType> {
470 match BybitProductType::from_suffix(instrument_id.symbol.as_str()) {
471 Some(product_type @ (BybitProductType::Linear | BybitProductType::Inverse)) => {
472 Ok(product_type)
473 }
474 Some(product_type) => {
475 anyhow::bail!("Liquidations not available for {product_type:?} instruments")
476 }
477 None => anyhow::bail!(
478 "Liquidations require a -LINEAR or -INVERSE instrument, received {instrument_id}"
479 ),
480 }
481}
482
483fn remove_owned_liquidation_sub(
487 subs: &AtomicMap<InstrumentId, Arc<DataType>>,
488 instrument_id: InstrumentId,
489 owned: &Arc<DataType>,
490) -> bool {
491 let mut removed = false;
492
493 subs.rcu(|map| {
494 removed = map
495 .get(&instrument_id)
496 .is_some_and(|current| Arc::ptr_eq(current, owned));
497
498 if removed {
499 map.remove(&instrument_id);
500 }
501 });
502
503 removed
504}
505
506type FundingCacheEntry = (Option<String>, Option<String>, Option<String>);
508
509#[expect(clippy::too_many_arguments)]
510fn handle_ws_message(
511 message: &BybitWsMessage,
512 data_sender: &EventSender<DataEvent>,
513 instruments: &AHashMap<Ustr, InstrumentAny>,
514 product_type: Option<BybitProductType>,
515 trade_subs: &Arc<AtomicSet<InstrumentId>>,
516 ticker_subs: &Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
517 quote_subs: &Arc<AtomicSet<InstrumentId>>,
518 book_depths: &Arc<AtomicMap<InstrumentId, u32>>,
519 book_sync: &BookSyncTracker,
520 ws_client: &BybitWebSocketClient,
521 option_greeks_subs: &Arc<AtomicSet<InstrumentId>>,
522 liquidation_subs: &Arc<AtomicMap<InstrumentId, Arc<DataType>>>,
523 bar_types_cache: &Arc<AtomicMap<String, BarType>>,
524 bars_timestamp_on_close: bool,
525 last_quotes: &DashMap<InstrumentId, QuoteTick>,
526 funding_cache: &mut AHashMap<Ustr, FundingCacheEntry>,
527 snapshot_timeout: Duration,
528 tasks: &TaskSpawner,
529 clock: &AtomicTime,
530) {
531 let ts_init = clock.get_time_ns();
532 let resolve = |raw_symbol: &Ustr| -> Option<&InstrumentAny> {
533 let key = product_type.map_or(*raw_symbol, |pt| make_bybit_symbol(raw_symbol, pt));
534 instruments.get(&key)
535 };
536
537 match message {
538 BybitWsMessage::Orderbook(msg) => {
539 let Some(instrument) = resolve(&msg.data.s) else {
540 log::warn!("Unknown symbol in orderbook update: {}", msg.data.s);
541 return;
542 };
543 let instrument_id = instrument.id();
544
545 let Ok((depth, symbol)) = parse_orderbook_topic(msg.topic.as_str()) else {
546 log::warn!("Invalid orderbook topic: {}", msg.topic);
547 return;
548 };
549
550 if symbol != msg.data.s.as_str() {
551 log::warn!(
552 "Orderbook topic symbol does not match payload: {}",
553 msg.topic
554 );
555 return;
556 }
557
558 if book_depths.load().get(&instrument_id) == Some(&depth) {
559 handle_orderbook_deltas(
560 msg,
561 instrument,
562 data_sender,
563 book_depths,
564 book_sync,
565 ws_client,
566 snapshot_timeout,
567 tasks,
568 ts_init,
569 );
570 }
571
572 if depth == BYBIT_QUOTE_DEPTH && quote_subs.contains(&instrument_id) {
573 match parse_orderbook_quote(msg, instrument, ts_init) {
574 Ok(quote) => {
575 send_data(data_sender, Data::Quote(quote));
576 }
577 Err(e) => log::error!("Failed to parse orderbook quote: {e}"),
578 }
579 }
580 }
581 BybitWsMessage::Trade(msg) => {
582 for trade in &msg.data {
583 let Some(instrument) = resolve(&trade.s) else {
584 continue;
585 };
586 let instrument_id = instrument.id();
587 if !trade_subs.contains(&instrument_id) {
588 continue;
589 }
590
591 match parse_ws_trade_tick(trade, instrument, ts_init) {
592 Ok(tick) => send_data(data_sender, Data::Trade(tick)),
593 Err(e) => log::error!("Failed to parse trade tick: {e}"),
594 }
595 }
596 }
597 BybitWsMessage::Liquidation(msg) => {
598 let subs = liquidation_subs.load();
599
600 for liquidation in &msg.data {
601 let Some(instrument) = resolve(&liquidation.s) else {
602 continue;
603 };
604
605 let Some(data_type) = subs.get(&instrument.id()) else {
607 continue;
608 };
609
610 match parse_ws_liquidation(liquidation, instrument, ts_init) {
611 Ok(liquidation) => {
612 let custom =
613 CustomData::new(Arc::new(liquidation), DataType::clone(data_type));
614 send_data(data_sender, Data::Custom(custom));
615 }
616 Err(e) => log::error!("Failed to parse liquidation: {e}"),
617 }
618 }
619 }
620 BybitWsMessage::Kline(msg) => {
621 let Ok((_, raw_symbol)) = parse_kline_topic(msg.topic.as_str()) else {
622 log::warn!("Invalid kline topic: {}", msg.topic);
623 return;
624 };
625 let ustr_symbol = Ustr::from(raw_symbol);
626 let Some(instrument) = resolve(&ustr_symbol) else {
627 log::warn!("Unknown symbol in kline update: {raw_symbol}");
628 return;
629 };
630 let topic_key = msg.topic.as_str();
631 let Some(bar_type) = bar_types_cache.load().get(topic_key).copied() else {
632 log::debug!("No bar type cached for kline topic: {topic_key}");
633 return;
634 };
635
636 for kline in &msg.data {
637 if !kline.confirm {
638 continue;
639 }
640
641 match parse_ws_kline_bar(
642 kline,
643 instrument,
644 bar_type,
645 bars_timestamp_on_close,
646 ts_init,
647 ) {
648 Ok(bar) => send_data(data_sender, Data::Bar(bar)),
649 Err(e) => log::error!("Failed to parse kline bar: {e}"),
650 }
651 }
652 }
653 BybitWsMessage::TickerLinear(msg) => {
654 let Some(instrument) = resolve(&msg.data.symbol) else {
655 log::warn!("Unknown symbol in ticker update: {}", msg.data.symbol);
656 return;
657 };
658 let instrument_id = instrument.id();
659 let subs = ticker_subs.load();
660 let sub_set = subs.get(&instrument_id);
661
662 let ts_event = match parse_millis_i64(msg.ts, "ticker.ts") {
663 Ok(ts) => ts,
664 Err(e) => {
665 log::error!("Failed to parse ticker timestamp: {e}");
666 return;
667 }
668 };
669
670 if sub_set.is_some_and(|s| s.contains("funding"))
671 && matches!(instrument, InstrumentAny::CryptoPerpetual(_))
672 {
673 let cache_entry = funding_cache
674 .entry(msg.data.symbol)
675 .or_insert((None, None, None));
676 let mut changed = false;
677
678 if let Some(rate) = &msg.data.funding_rate
679 && cache_entry.0.as_ref() != Some(rate)
680 {
681 cache_entry.0 = Some(rate.clone());
682 changed = true;
683 }
684
685 if let Some(next_time) = &msg.data.next_funding_time
686 && cache_entry.1.as_ref() != Some(next_time)
687 {
688 cache_entry.1 = Some(next_time.clone());
689 changed = true;
690 }
691
692 if let Some(interval) = &msg.data.funding_interval_hour {
693 cache_entry.2 = Some(interval.clone());
694 }
695
696 if changed && cache_entry.0.is_some() {
697 let mut merged = msg.data.clone();
698
699 if merged.funding_rate.is_none() {
700 merged.funding_rate.clone_from(&cache_entry.0);
701 }
702
703 if merged.next_funding_time.is_none() {
704 merged.next_funding_time.clone_from(&cache_entry.1);
705 }
706
707 if merged.funding_interval_hour.is_none() {
708 merged.funding_interval_hour.clone_from(&cache_entry.2);
709 }
710
711 match parse_ticker_linear_funding(&merged, instrument_id, ts_event, ts_init) {
712 Ok(update) => {
713 if let Err(e) = data_sender.send(DataEvent::FundingRate(update)) {
714 log::error!("Failed to emit funding rate event: {e}");
715 }
716 }
717 Err(e) => log::error!("Failed to parse ticker linear funding: {e}"),
718 }
719 }
720 }
721
722 if sub_set.is_some_and(|s| s.contains("mark_prices")) && msg.data.mark_price.is_some() {
723 match parse_ticker_linear_mark_price(&msg.data, instrument, ts_event, ts_init) {
724 Ok(update) => send_data(data_sender, Data::MarkPrice(update)),
725 Err(e) => log::debug!("Skipping mark price update: {e}"),
726 }
727 }
728
729 if sub_set.is_some_and(|s| s.contains("index_prices")) && msg.data.index_price.is_some()
730 {
731 match parse_ticker_linear_index_price(&msg.data, instrument, ts_event, ts_init) {
732 Ok(update) => send_data(data_sender, Data::IndexPrice(update)),
733 Err(e) => log::debug!("Skipping index price update: {e}"),
734 }
735 }
736 }
737 BybitWsMessage::TickerOption(msg) => {
738 let Some(instrument) = resolve(&msg.data.symbol) else {
739 log::warn!(
740 "Unknown symbol in option ticker update: {}",
741 msg.data.symbol
742 );
743 return;
744 };
745 let instrument_id = instrument.id();
746 let subs = ticker_subs.load();
747 let sub_set = subs.get(&instrument_id);
748
749 if sub_set.is_some_and(|s| s.contains("quotes")) {
750 match parse_ticker_option_quote(msg, instrument, ts_init) {
751 Ok(quote) => {
752 let last = last_quotes.get(&instrument_id).map(|quote| *quote);
753 if last.is_none_or(|q| !q.has_same_bid_ask("e)) {
754 last_quotes.insert(instrument_id, quote);
755 send_data(data_sender, Data::Quote(quote));
756 }
757 }
758 Err(e) => log::error!("Failed to parse ticker option quote: {e}"),
759 }
760 }
761
762 if sub_set.is_some_and(|s| s.contains("mark_prices")) {
763 match parse_ticker_option_mark_price(msg, instrument, ts_init) {
764 Ok(update) => send_data(data_sender, Data::MarkPrice(update)),
765 Err(e) => log::error!("Failed to parse ticker option mark price: {e}"),
766 }
767 }
768
769 if sub_set.is_some_and(|s| s.contains("index_prices")) {
770 match parse_ticker_option_index_price(msg, instrument, ts_init) {
771 Ok(update) => send_data(data_sender, Data::IndexPrice(update)),
772 Err(e) => log::error!("Failed to parse ticker option index price: {e}"),
773 }
774 }
775
776 if option_greeks_subs.contains(&instrument_id) {
777 match parse_ticker_option_greeks(msg, instrument, ts_init) {
778 Ok(greeks) => {
779 if let Err(e) = data_sender.send(DataEvent::OptionGreeks(greeks)) {
780 log::error!("Failed to send option greeks: {e}");
781 }
782 }
783 Err(e) => log::error!("Failed to parse option greeks: {e}"),
784 }
785 }
786 }
787 BybitWsMessage::Reconnected => {
788 reset_option_quotes(last_quotes, product_type);
789 funding_cache.clear();
790 log::info!("WebSocket reconnected, cleared caches");
791
792 if let Some(product_type) = product_type {
793 reset_on_reconnect(
794 product_type,
795 book_depths,
796 book_sync,
797 ws_client,
798 snapshot_timeout,
799 tasks,
800 );
801 }
802 }
803 BybitWsMessage::Error(e) => {
804 log::warn!(
805 "Bybit WebSocket error: code={} message={}",
806 e.code,
807 e.message
808 );
809 }
810 BybitWsMessage::Auth(_)
811 | BybitWsMessage::OrderResponse(_)
812 | BybitWsMessage::AccountOrder(_)
813 | BybitWsMessage::AccountExecution(_)
814 | BybitWsMessage::AccountExecutionFast(_)
815 | BybitWsMessage::AccountWallet(_)
816 | BybitWsMessage::AccountPosition(_) => {}
817 }
818}
819
820#[expect(clippy::too_many_arguments)]
822fn handle_orderbook_deltas(
823 msg: &BybitWsOrderbookDepthMsg,
824 instrument: &InstrumentAny,
825 data_sender: &EventSender<DataEvent>,
826 book_depths: &AtomicMap<InstrumentId, u32>,
827 book_sync: &BookSyncTracker,
828 ws_client: &BybitWebSocketClient,
829 snapshot_timeout: Duration,
830 tasks: &TaskSpawner,
831 ts_init: UnixNanos,
832) {
833 let instrument_id = instrument.id();
834 let is_snapshot = msg.msg_type.eq_ignore_ascii_case("snapshot");
835
836 let outcome = match u64::try_from(msg.data.u) {
837 Ok(update_id) => book_sync.validate(instrument_id, is_snapshot, update_id, Instant::now()),
838 Err(e) => {
839 log::error!(
840 "Invalid orderbook update ID {} for {instrument_id}: {e}",
841 msg.data.u
842 );
843 BookSequenceOutcome::Recover
844 }
845 };
846
847 match outcome {
848 BookSequenceOutcome::Accept => {}
849 BookSequenceOutcome::Suppress => return,
850 BookSequenceOutcome::Recover => {
851 start_recovery(
852 instrument_id,
853 book_depths,
854 book_sync,
855 ws_client,
856 snapshot_timeout,
857 tasks,
858 );
859 return;
860 }
861 }
862
863 if !is_snapshot && msg.data.b.is_empty() && msg.data.a.is_empty() {
864 return;
865 }
866
867 match parse_orderbook_deltas(msg, instrument, ts_init) {
868 Ok(deltas) => send_data(data_sender, Data::BookDeltas(Box::new(deltas))),
869 Err(e) => {
870 log::error!("Failed to parse orderbook deltas for {instrument_id}: {e}");
871 start_recovery(
872 instrument_id,
873 book_depths,
874 book_sync,
875 ws_client,
876 snapshot_timeout,
877 tasks,
878 );
879 }
880 }
881}
882
883fn upsert_instrument(
884 cache: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
885 instrument: InstrumentAny,
886) {
887 cache.insert(instrument.id(), instrument);
888}
889
890fn reset_option_quotes(
891 last_quotes: &DashMap<InstrumentId, QuoteTick>,
892 product_type: Option<BybitProductType>,
893) {
894 if product_type == Some(BybitProductType::Option) {
895 last_quotes.clear();
896 }
897}
898
899#[async_trait::async_trait(?Send)]
900impl DataClient for BybitDataClient {
901 fn client_id(&self) -> ClientId {
902 self.client_id
903 }
904
905 fn venue(&self) -> Option<Venue> {
906 Some(self.venue())
907 }
908
909 fn start(&mut self) -> anyhow::Result<()> {
910 log::info!(
911 "Started: client_id={}, product_types={:?}, environment={:?}, proxy_url={:?}",
912 self.client_id,
913 self.config.product_types,
914 self.config.environment,
915 self.config.proxy_url,
916 );
917 Ok(())
918 }
919
920 fn stop(&mut self) -> anyhow::Result<()> {
921 log::info!("Stopping {id}", id = self.client_id);
922 self.session_tasks.begin_shutdown();
923 self.command_tasks.begin_shutdown();
924 for ws_client in &self.ws_clients {
925 ws_client.begin_shutdown();
926 }
927 self.session_established.store(false, Ordering::Relaxed);
928 Ok(())
929 }
930
931 fn reset(&mut self) -> anyhow::Result<()> {
932 log::debug!("Resetting {id}", id = self.client_id);
933 self.session_tasks.begin_shutdown();
934 self.command_tasks.begin_shutdown();
935 for ws_client in &self.ws_clients {
936 ws_client.begin_shutdown();
937 }
938 self.session_established.store(false, Ordering::Relaxed);
939 self.book_depths.store(AHashMap::new());
940 self.book_sync.clear();
941 self.quote_subs.store(AHashSet::new());
942 self.last_quotes.clear();
943 self.ticker_subs.store(AHashMap::new());
944 self.option_greeks_subs.store(AHashSet::new());
945 self.liquidation_subs.store(AHashMap::new());
946 self.instrument_status_subs.store(AHashSet::new());
947 self.status_cache.store(AHashMap::new());
948 self.instrument_subs.store(AHashSet::new());
949 self.subscribe_all_instruments
950 .store(false, Ordering::Relaxed);
951 Ok(())
952 }
953
954 fn dispose(&mut self) -> anyhow::Result<()> {
955 log::debug!("Disposing {id}", id = self.client_id);
956 self.stop()
957 }
958
959 async fn connect(&mut self) -> anyhow::Result<()> {
960 if self.session_established.load(Ordering::Relaxed)
961 && self.session_tasks.is_open()
962 && self.command_tasks.is_open()
963 {
964 return Ok(());
965 }
966
967 register_bybit_custom_data();
968
969 self.prepare_task_groups().await?;
970 self.last_quotes.clear();
971 let ws_clients = self.ws_clients.clone();
972 let setup_guard =
973 TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
974 for ws_client in ws_clients {
975 ws_client.begin_shutdown();
976 }
977 });
978
979 let product_types = if self.config.product_types.is_empty() {
980 vec![BybitProductType::Linear]
981 } else {
982 self.config.product_types.clone()
983 };
984
985 let mut all_instruments = Vec::new();
986
987 for product_type in &product_types {
988 let fetched = self
989 .http_client
990 .request_instruments(*product_type, None, None)
991 .await
992 .with_context(|| {
993 format!("failed to request Bybit instruments for {product_type:?}")
994 })?;
995
996 self.http_client.cache_instruments(&fetched);
997
998 self.instruments.rcu(|m| {
999 for instrument in &fetched {
1000 m.insert(instrument.id(), instrument.clone());
1001 }
1002 });
1003
1004 all_instruments.extend(fetched);
1005 }
1006
1007 if self
1009 .config
1010 .instrument_poll_interval_secs
1011 .is_some_and(|s| s > 0)
1012 {
1013 let mut collected_statuses = Vec::new();
1015
1016 for product_type in &product_types {
1017 match self
1018 .http_client
1019 .request_instrument_statuses(*product_type)
1020 .await
1021 {
1022 Ok(statuses) => collected_statuses.push(statuses),
1023 Err(e) => {
1024 log::warn!(
1025 "Failed to seed instrument status cache for {product_type:?}: {e}"
1026 );
1027 }
1028 }
1029 }
1030
1031 let inst_guard = self.instruments.load();
1032 let mut status_map = AHashMap::new();
1033
1034 for statuses in collected_statuses {
1035 for (id, action) in statuses {
1036 if inst_guard.contains_key(&id) {
1037 status_map.insert(id, action);
1038 }
1039 }
1040 }
1041 log::debug!(
1042 "Seeded instrument status cache with {} entries",
1043 status_map.len()
1044 );
1045 self.status_cache.store(status_map);
1046 }
1047
1048 for instrument in all_instruments {
1049 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
1050 log::warn!("Failed to send instrument: {e}");
1051 }
1052 }
1053
1054 let instruments_by_symbol: Arc<AHashMap<Ustr, InstrumentAny>> = {
1056 let guard = self.instruments.load();
1057 let mut map = AHashMap::new();
1058 for instrument in guard.values() {
1059 map.insert(instrument.id().symbol.inner(), instrument.clone());
1060 }
1061 Arc::new(map)
1062 };
1063
1064 let session_result = async {
1065 for ws_client in &mut self.ws_clients {
1066 ws_client
1067 .connect()
1068 .await
1069 .context("failed to connect Bybit WebSocket")?;
1070 ws_client
1071 .wait_until_active(10.0)
1072 .await
1073 .context("WebSocket did not become active")?;
1074
1075 let stream = ws_client.stream();
1076 let product_type = ws_client.product_type();
1077 let sender = self.data_sender.clone();
1078 let trade_subs = self.trade_subs.clone();
1079 let ticker_subs = self.ticker_subs.clone();
1080 let quote_subs = self.quote_subs.clone();
1081 let last_quotes = self.last_quotes.clone();
1082 let book_depths = self.book_depths.clone();
1083 let book_sync = self.book_sync.clone();
1084 let snapshot_timeout = Duration::from_secs(self.config.book_snapshot_timeout_secs);
1085 let tasks = self
1086 .command_tasks
1087 .spawner()
1088 .context("Bybit data command task admission is closed")?;
1089 let option_greeks_subs = self.option_greeks_subs.clone();
1090 let liquidation_subs = self.liquidation_subs.clone();
1091 let bar_types_cache = ws_client.bar_types_cache().clone();
1092 let bars_timestamp_on_close = self.config.bars_timestamp_on_close;
1093 let instruments = Arc::clone(&instruments_by_symbol);
1094 let clock = self.clock;
1095 let cancel = self.cancellation_token.clone();
1096 let ws_client = ws_client.clone();
1097
1098 let future = async move {
1099 let mut funding_cache: AHashMap<Ustr, FundingCacheEntry> = AHashMap::new();
1100
1101 pin_mut!(stream);
1102
1103 loop {
1104 tokio::select! {
1105 Some(message) = stream.next() => {
1106 handle_ws_message(
1107 &message,
1108 &sender,
1109 &instruments,
1110 product_type,
1111 &trade_subs,
1112 &ticker_subs,
1113 "e_subs,
1114 &book_depths,
1115 &book_sync,
1116 &ws_client,
1117 &option_greeks_subs,
1118 &liquidation_subs,
1119 &bar_types_cache,
1120 bars_timestamp_on_close,
1121 &last_quotes,
1122 &mut funding_cache,
1123 snapshot_timeout,
1124 &tasks,
1125 clock,
1126 );
1127 }
1128 () = cancel.cancelled() => {
1129 log::debug!("WebSocket stream task cancelled");
1130 break;
1131 }
1132 }
1133 }
1134 };
1135 self.session_tasks
1136 .spawn(future)
1137 .context("failed to register Bybit WebSocket stream task")?;
1138 }
1139
1140 if let Some(poll_secs) = self.config.instrument_poll_interval_secs
1141 && poll_secs > 0
1142 {
1143 self.spawn_instrument_polling(&product_types, poll_secs)?;
1144 }
1145
1146 Ok::<(), anyhow::Error>(())
1147 }
1148 .await;
1149
1150 if let Err(e) = session_result {
1151 if let Err(teardown_error) = self.teardown_partial_connect().await {
1152 return Err(e.context(format!(
1153 "Bybit data startup teardown failed: {teardown_error}"
1154 )));
1155 }
1156 return Err(e);
1157 }
1158
1159 setup_guard.disarm();
1160 self.session_established.store(true, Ordering::Release);
1161 log::info!("Connected: client_id={}", self.client_id);
1162 Ok(())
1163 }
1164
1165 async fn disconnect(&mut self) -> anyhow::Result<()> {
1166 self.session_tasks.begin_shutdown();
1167 self.command_tasks.begin_shutdown();
1168 for ws_client in &self.ws_clients {
1169 ws_client.begin_shutdown();
1170 }
1171
1172 for ws_client in &mut self.ws_clients {
1173 if let Err(e) = ws_client.close().await {
1174 self.shutdown_errors.push(e.to_string());
1175 }
1176 }
1177
1178 tokio::time::sleep(Duration::from_millis(500)).await;
1180
1181 if let Err(e) = self.finish_tasks().await {
1182 self.shutdown_errors.push(e.to_string());
1183 }
1184
1185 self.book_depths.store(AHashMap::new());
1186 self.book_sync.clear();
1187 self.quote_subs.store(AHashSet::new());
1188 self.last_quotes.clear();
1189 self.ticker_subs.store(AHashMap::new());
1190 self.trade_subs.store(AHashSet::new());
1191 self.option_greeks_subs.store(AHashSet::new());
1192 self.liquidation_subs.store(AHashMap::new());
1193 self.instrument_status_subs.store(AHashSet::new());
1194 self.status_cache.store(AHashMap::new());
1195 self.instrument_subs.store(AHashSet::new());
1196 self.subscribe_all_instruments
1197 .store(false, Ordering::Relaxed);
1198 self.session_established.store(false, Ordering::Release);
1199 log::info!("Disconnected: client_id={}", self.client_id);
1200
1201 if self.shutdown_errors.is_empty() {
1202 Ok(())
1203 } else {
1204 let errors = std::mem::take(&mut self.shutdown_errors);
1205 anyhow::bail!("Bybit data shutdown failed: {}", errors.join("; "))
1206 }
1207 }
1208
1209 fn is_connected(&self) -> bool {
1210 self.session_established.load(Ordering::Relaxed)
1211 && self.ws_clients.iter().all(BybitWebSocketClient::is_active)
1212 }
1213
1214 fn is_disconnected(&self) -> bool {
1215 !self.is_connected()
1216 }
1217
1218 fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
1219 let data_type = cmd.data_type.type_name();
1220 if data_type != "BybitLiquidation" {
1221 log::warn!("Unsupported custom data subscription: {data_type}");
1222 return Ok(());
1223 }
1224
1225 let instrument_id = liquidation_instrument_id(&cmd.data_type, self.venue())?;
1226 let product_type = liquidation_product_type(instrument_id)?;
1227
1228 let ws = self
1229 .get_ws_client_for_product(product_type)
1230 .context("no WebSocket client for product type")?
1231 .clone();
1232
1233 if let Some(subscribed) = self.liquidation_subs.get_cloned(&instrument_id) {
1234 anyhow::ensure!(
1235 *subscribed == cmd.data_type,
1236 "Already subscribed to liquidations for {instrument_id} as {subscribed}"
1237 );
1238 return Ok(());
1239 }
1240
1241 let subscription = Arc::new(cmd.data_type);
1242 self.liquidation_subs
1243 .insert(instrument_id, Arc::clone(&subscription));
1244 let liquidation_subs = Arc::clone(&self.liquidation_subs);
1245
1246 self.spawn_ws(
1247 async move {
1248 if let Err(e) = ws.subscribe_liquidations(instrument_id).await {
1249 if remove_owned_liquidation_sub(&liquidation_subs, instrument_id, &subscription)
1251 && let Err(e) = ws.unsubscribe_liquidations(instrument_id).await
1252 {
1253 log::warn!(
1254 "Failed to unsubscribe after liquidation subscription error: {e}"
1255 );
1256 }
1257
1258 return Err(e).context("liquidations subscription");
1259 }
1260
1261 Ok(())
1262 },
1263 "liquidation subscription",
1264 );
1265
1266 Ok(())
1267 }
1268
1269 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1270 if cmd.book_type != BookType::L2_MBP {
1271 anyhow::bail!("Bybit only supports L2_MBP order book deltas");
1272 }
1273
1274 let depth = cmd
1275 .depth
1276 .map_or(BYBIT_DEFAULT_ORDERBOOK_DEPTH, |d| d.get() as u32);
1277
1278 validate_orderbook_depth(depth)?;
1279
1280 let instrument_id = cmd.instrument_id;
1281 let product_type = self
1282 .get_product_type_for_instrument(instrument_id)
1283 .unwrap_or(BybitProductType::Linear);
1284
1285 let ws_client = self
1286 .get_ws_client_for_product(product_type)
1287 .context("no WebSocket client for product type")?
1288 .clone();
1289
1290 if let Some(subscribed_depth) = self.book_depths.load().get(&instrument_id) {
1291 anyhow::ensure!(
1292 *subscribed_depth == depth,
1293 "Already subscribed to book depth {subscribed_depth} for {instrument_id}"
1294 );
1295 return Ok(());
1296 }
1297
1298 let tasks = match self.command_tasks.spawner() {
1299 Ok(tasks) => tasks,
1300 Err(e) => {
1301 log::warn!("Skipping Bybit data command after shutdown began: {e}");
1302 return Ok(());
1303 }
1304 };
1305
1306 spawn_subscription_task(
1307 instrument_id,
1308 depth,
1309 Arc::clone(&self.book_depths),
1310 self.book_sync.clone(),
1311 ws_client,
1312 Duration::from_secs(self.config.book_snapshot_timeout_secs),
1313 &tasks,
1314 );
1315
1316 Ok(())
1317 }
1318
1319 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
1320 let instrument_id = cmd.instrument_id;
1321 let product_type = self
1322 .get_product_type_for_instrument(instrument_id)
1323 .unwrap_or(BybitProductType::Linear);
1324
1325 let ws = self
1326 .get_ws_client_for_product(product_type)
1327 .context("no WebSocket client for product type")?
1328 .clone();
1329
1330 if product_type == BybitProductType::Option {
1331 self.last_quotes.remove(&instrument_id);
1332 let mut should_subscribe = false;
1333 self.ticker_subs.rcu(|m| {
1334 let entry = m.entry(instrument_id).or_default();
1335 should_subscribe = entry.is_empty();
1336 entry.insert("quotes");
1337 });
1338
1339 if should_subscribe {
1340 self.spawn_ws(
1341 async move {
1342 ws.subscribe_ticker(instrument_id)
1343 .await
1344 .context("ticker subscription")
1345 },
1346 "quote subscription",
1347 );
1348 }
1349 } else {
1350 if self.quote_subs.contains(&instrument_id) {
1351 return Ok(());
1352 }
1353
1354 self.quote_subs.insert(instrument_id);
1355 let quote_subs = Arc::clone(&self.quote_subs);
1356
1357 self.spawn_ws(
1358 async move {
1359 if let Err(e) = ws
1360 .subscribe_orderbook(instrument_id, BYBIT_QUOTE_DEPTH)
1361 .await
1362 {
1363 let mut removed = false;
1367 quote_subs.rcu(|subs| removed = subs.remove(&instrument_id));
1368
1369 if removed
1370 && let Err(e) = ws
1371 .unsubscribe_orderbook(instrument_id, BYBIT_QUOTE_DEPTH)
1372 .await
1373 {
1374 log::warn!("Failed to unsubscribe after quote subscription error: {e}");
1375 }
1376
1377 return Err(e).context("orderbook subscription for quotes");
1378 }
1379
1380 Ok(())
1381 },
1382 "quote subscription (orderbook)",
1383 );
1384 }
1385 Ok(())
1386 }
1387
1388 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
1389 let instrument_id = cmd.instrument_id;
1390 let product_type = self
1391 .get_product_type_for_instrument(instrument_id)
1392 .unwrap_or(BybitProductType::Linear);
1393
1394 self.trade_subs.insert(instrument_id);
1395
1396 let ws = self
1397 .get_ws_client_for_product(product_type)
1398 .context("no WebSocket client for product type")?
1399 .clone();
1400
1401 self.spawn_ws(
1402 async move {
1403 ws.subscribe_trades(instrument_id)
1404 .await
1405 .context("trades subscription")
1406 },
1407 "trade subscription",
1408 );
1409 Ok(())
1410 }
1411
1412 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
1413 let instrument_id = cmd.instrument_id;
1414 let product_type = self
1415 .get_product_type_for_instrument(instrument_id)
1416 .unwrap_or(BybitProductType::Linear);
1417
1418 if product_type == BybitProductType::Spot || product_type == BybitProductType::Option {
1419 anyhow::bail!("Funding rates not available for {product_type:?} instruments");
1420 }
1421
1422 let guard = self.instruments.load();
1423 if let Some(instrument) = guard.get(&instrument_id)
1424 && !matches!(instrument, InstrumentAny::CryptoPerpetual(_))
1425 {
1426 anyhow::bail!("Funding rates only available for perpetuals, not {instrument_id}");
1427 }
1428
1429 let mut should_subscribe = false;
1430 self.ticker_subs.rcu(|m| {
1431 let entry = m.entry(instrument_id).or_default();
1432 should_subscribe = entry.is_empty();
1433 entry.insert("funding");
1434 });
1435
1436 if should_subscribe {
1437 let ws = self
1438 .get_ws_client_for_product(product_type)
1439 .context("no WebSocket client for product type")?
1440 .clone();
1441
1442 self.spawn_ws(
1443 async move {
1444 ws.subscribe_ticker(instrument_id)
1445 .await
1446 .context("ticker subscription for funding rates")
1447 },
1448 "funding rate subscription",
1449 );
1450 }
1451 Ok(())
1452 }
1453
1454 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
1455 let instrument_id = cmd.instrument_id;
1456 let product_type = self
1457 .get_product_type_for_instrument(instrument_id)
1458 .unwrap_or(BybitProductType::Linear);
1459
1460 if product_type == BybitProductType::Spot {
1461 anyhow::bail!("Mark prices not available for Spot instruments");
1462 }
1463
1464 let mut should_subscribe = false;
1465 self.ticker_subs.rcu(|m| {
1466 let entry = m.entry(instrument_id).or_default();
1467 should_subscribe = entry.is_empty();
1468 entry.insert("mark_prices");
1469 });
1470
1471 if should_subscribe {
1472 let ws = self
1473 .get_ws_client_for_product(product_type)
1474 .context("no WebSocket client for product type")?
1475 .clone();
1476
1477 self.spawn_ws(
1478 async move {
1479 ws.subscribe_ticker(instrument_id)
1480 .await
1481 .context("ticker subscription for mark prices")
1482 },
1483 "mark price subscription",
1484 );
1485 }
1486 Ok(())
1487 }
1488
1489 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
1490 let instrument_id = cmd.instrument_id;
1491 let product_type = self
1492 .get_product_type_for_instrument(instrument_id)
1493 .unwrap_or(BybitProductType::Linear);
1494
1495 if product_type == BybitProductType::Spot {
1496 anyhow::bail!("Index prices not available for Spot instruments");
1497 }
1498
1499 let mut should_subscribe = false;
1500 self.ticker_subs.rcu(|m| {
1501 let entry = m.entry(instrument_id).or_default();
1502 should_subscribe = entry.is_empty();
1503 entry.insert("index_prices");
1504 });
1505
1506 if should_subscribe {
1507 let ws = self
1508 .get_ws_client_for_product(product_type)
1509 .context("no WebSocket client for product type")?
1510 .clone();
1511
1512 self.spawn_ws(
1513 async move {
1514 ws.subscribe_ticker(instrument_id)
1515 .await
1516 .context("ticker subscription for index prices")
1517 },
1518 "index price subscription",
1519 );
1520 }
1521 Ok(())
1522 }
1523
1524 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
1525 let bar_type = cmd.bar_type;
1526 let instrument_id = bar_type.instrument_id();
1527 let product_type = self
1528 .get_product_type_for_instrument(instrument_id)
1529 .unwrap_or(BybitProductType::Linear);
1530
1531 if product_type == BybitProductType::Option {
1532 anyhow::bail!("Bybit does not support kline/bar data for options");
1533 }
1534
1535 let ws = self
1536 .get_ws_client_for_product(product_type)
1537 .context("no WebSocket client for product type")?
1538 .clone();
1539
1540 self.spawn_ws(
1541 async move {
1542 ws.subscribe_bars(bar_type)
1543 .await
1544 .context("bars subscription")
1545 },
1546 "bar subscription",
1547 );
1548 Ok(())
1549 }
1550
1551 fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
1552 let data_type = cmd.data_type.type_name();
1553 if data_type != "BybitLiquidation" {
1554 log::warn!("Unsupported custom data unsubscription: {data_type}");
1555 return Ok(());
1556 }
1557
1558 let instrument_id = liquidation_instrument_id(&cmd.data_type, self.venue())?;
1559
1560 let Some(subscription) = self
1561 .liquidation_subs
1562 .get_cloned(&instrument_id)
1563 .filter(|subscribed| **subscribed == cmd.data_type)
1564 else {
1565 return Ok(());
1566 };
1567
1568 let product_type = liquidation_product_type(instrument_id)?;
1569
1570 let ws = self
1571 .get_ws_client_for_product(product_type)
1572 .context("no WebSocket client for product type")?
1573 .clone();
1574
1575 if !remove_owned_liquidation_sub(&self.liquidation_subs, instrument_id, &subscription) {
1577 return Ok(());
1578 }
1579
1580 self.spawn_ws(
1581 async move {
1582 ws.unsubscribe_liquidations(instrument_id)
1583 .await
1584 .context("liquidations unsubscribe")
1585 },
1586 "liquidation unsubscribe",
1587 );
1588
1589 Ok(())
1590 }
1591
1592 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1593 let instrument_id = cmd.instrument_id;
1594 let Some(depth) = self.book_depths.load().get(&instrument_id).copied() else {
1595 return Ok(());
1596 };
1597
1598 self.book_depths.remove(&instrument_id);
1599 self.book_sync.remove(instrument_id);
1600
1601 let product_type = self
1602 .get_product_type_for_instrument(instrument_id)
1603 .unwrap_or(BybitProductType::Linear);
1604
1605 let ws = self
1606 .get_ws_client_for_product(product_type)
1607 .context("no WebSocket client for product type")?
1608 .clone();
1609
1610 self.spawn_ws(
1611 async move {
1612 ws.unsubscribe_orderbook(instrument_id, depth)
1613 .await
1614 .context("orderbook unsubscribe")
1615 },
1616 "order book unsubscribe",
1617 );
1618 Ok(())
1619 }
1620
1621 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1622 let instrument_id = cmd.instrument_id;
1623 let product_type = self
1624 .get_product_type_for_instrument(instrument_id)
1625 .unwrap_or(BybitProductType::Linear);
1626
1627 let ws = self
1628 .get_ws_client_for_product(product_type)
1629 .context("no WebSocket client for product type")?
1630 .clone();
1631
1632 if product_type == BybitProductType::Option {
1633 self.last_quotes.remove(&instrument_id);
1634 let mut should_unsubscribe = false;
1635 self.ticker_subs.rcu(|m| {
1636 if let Some(entry) = m.get_mut(&instrument_id) {
1637 entry.remove("quotes");
1638 if entry.is_empty() {
1639 m.remove(&instrument_id);
1640 should_unsubscribe = true;
1641 } else {
1642 should_unsubscribe = false;
1643 }
1644 } else {
1645 should_unsubscribe = false;
1646 }
1647 });
1648
1649 if should_unsubscribe {
1650 self.spawn_ws(
1651 async move {
1652 ws.unsubscribe_ticker(instrument_id)
1653 .await
1654 .context("ticker unsubscribe")
1655 },
1656 "quote unsubscribe",
1657 );
1658 }
1659 } else {
1660 if !self.quote_subs.contains(&instrument_id) {
1661 return Ok(());
1662 }
1663
1664 self.quote_subs.remove(&instrument_id);
1665
1666 self.spawn_ws(
1667 async move {
1668 ws.unsubscribe_orderbook(instrument_id, BYBIT_QUOTE_DEPTH)
1669 .await
1670 .context("orderbook unsubscribe for quotes")
1671 },
1672 "quote unsubscribe (orderbook)",
1673 );
1674 }
1675 Ok(())
1676 }
1677
1678 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1679 let instrument_id = cmd.instrument_id;
1680 let product_type = self
1681 .get_product_type_for_instrument(instrument_id)
1682 .unwrap_or(BybitProductType::Linear);
1683
1684 self.trade_subs.remove(&instrument_id);
1685
1686 let ws = self
1687 .get_ws_client_for_product(product_type)
1688 .context("no WebSocket client for product type")?
1689 .clone();
1690
1691 self.spawn_ws(
1692 async move {
1693 ws.unsubscribe_trades(instrument_id)
1694 .await
1695 .context("trades unsubscribe")
1696 },
1697 "trade unsubscribe",
1698 );
1699 Ok(())
1700 }
1701
1702 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
1703 let instrument_id = cmd.instrument_id;
1704 let product_type = self
1705 .get_product_type_for_instrument(instrument_id)
1706 .unwrap_or(BybitProductType::Linear);
1707
1708 let mut should_unsubscribe = false;
1709 self.ticker_subs.rcu(|m| {
1710 if let Some(entry) = m.get_mut(&instrument_id) {
1711 entry.remove("funding");
1712 if entry.is_empty() {
1713 m.remove(&instrument_id);
1714 should_unsubscribe = true;
1715 } else {
1716 should_unsubscribe = false;
1717 }
1718 } else {
1719 should_unsubscribe = false;
1720 }
1721 });
1722
1723 if should_unsubscribe {
1724 let ws = self
1725 .get_ws_client_for_product(product_type)
1726 .context("no WebSocket client for product type")?
1727 .clone();
1728
1729 self.spawn_ws(
1730 async move {
1731 ws.unsubscribe_ticker(instrument_id)
1732 .await
1733 .context("ticker unsubscribe for funding rates")
1734 },
1735 "funding rate unsubscribe",
1736 );
1737 }
1738 Ok(())
1739 }
1740
1741 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
1742 let instrument_id = cmd.instrument_id;
1743 let product_type = self
1744 .get_product_type_for_instrument(instrument_id)
1745 .unwrap_or(BybitProductType::Linear);
1746
1747 let mut should_unsubscribe = false;
1748 self.ticker_subs.rcu(|m| {
1749 if let Some(entry) = m.get_mut(&instrument_id) {
1750 entry.remove("mark_prices");
1751 if entry.is_empty() {
1752 m.remove(&instrument_id);
1753 should_unsubscribe = true;
1754 } else {
1755 should_unsubscribe = false;
1756 }
1757 } else {
1758 should_unsubscribe = false;
1759 }
1760 });
1761
1762 if should_unsubscribe {
1763 let ws = self
1764 .get_ws_client_for_product(product_type)
1765 .context("no WebSocket client for product type")?
1766 .clone();
1767
1768 self.spawn_ws(
1769 async move {
1770 ws.unsubscribe_ticker(instrument_id)
1771 .await
1772 .context("ticker unsubscribe for mark prices")
1773 },
1774 "mark price unsubscribe",
1775 );
1776 }
1777 Ok(())
1778 }
1779
1780 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
1781 let instrument_id = cmd.instrument_id;
1782 let product_type = self
1783 .get_product_type_for_instrument(instrument_id)
1784 .unwrap_or(BybitProductType::Linear);
1785
1786 let mut should_unsubscribe = false;
1787 self.ticker_subs.rcu(|m| {
1788 if let Some(entry) = m.get_mut(&instrument_id) {
1789 entry.remove("index_prices");
1790 if entry.is_empty() {
1791 m.remove(&instrument_id);
1792 should_unsubscribe = true;
1793 } else {
1794 should_unsubscribe = false;
1795 }
1796 } else {
1797 should_unsubscribe = false;
1798 }
1799 });
1800
1801 if should_unsubscribe {
1802 let ws = self
1803 .get_ws_client_for_product(product_type)
1804 .context("no WebSocket client for product type")?
1805 .clone();
1806
1807 self.spawn_ws(
1808 async move {
1809 ws.unsubscribe_ticker(instrument_id)
1810 .await
1811 .context("ticker unsubscribe for index prices")
1812 },
1813 "index price unsubscribe",
1814 );
1815 }
1816 Ok(())
1817 }
1818
1819 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1820 let bar_type = cmd.bar_type;
1821 let instrument_id = bar_type.instrument_id();
1822 let product_type = self
1823 .get_product_type_for_instrument(instrument_id)
1824 .unwrap_or(BybitProductType::Linear);
1825
1826 let ws = self
1827 .get_ws_client_for_product(product_type)
1828 .context("no WebSocket client for product type")?
1829 .clone();
1830
1831 self.spawn_ws(
1832 async move {
1833 ws.unsubscribe_bars(bar_type)
1834 .await
1835 .context("bars unsubscribe")
1836 },
1837 "bar unsubscribe",
1838 );
1839 Ok(())
1840 }
1841
1842 fn subscribe_option_greeks(&mut self, cmd: SubscribeOptionGreeks) -> anyhow::Result<()> {
1843 let instrument_id = cmd.instrument_id;
1844 self.option_greeks_subs.insert(instrument_id);
1845
1846 let mut should_subscribe = false;
1847 self.ticker_subs.rcu(|m| {
1848 let entry = m.entry(instrument_id).or_default();
1849 should_subscribe = entry.is_empty();
1850 entry.insert("option_greeks");
1851 });
1852
1853 if should_subscribe {
1854 let product_type = self
1855 .get_product_type_for_instrument(instrument_id)
1856 .unwrap_or(BybitProductType::Option);
1857
1858 let ws = self
1859 .get_ws_client_for_product(product_type)
1860 .context("no WebSocket client for product type")?
1861 .clone();
1862
1863 self.spawn_ws(
1864 async move {
1865 ws.subscribe_ticker(instrument_id)
1866 .await
1867 .context("ticker subscription for option greeks")
1868 },
1869 "option greeks subscription",
1870 );
1871 }
1872 Ok(())
1873 }
1874
1875 fn unsubscribe_option_greeks(&mut self, cmd: &UnsubscribeOptionGreeks) -> anyhow::Result<()> {
1876 let instrument_id = cmd.instrument_id;
1877 self.option_greeks_subs.remove(&instrument_id);
1878
1879 let mut should_unsubscribe = false;
1880 self.ticker_subs.rcu(|m| {
1881 if let Some(entry) = m.get_mut(&instrument_id) {
1882 entry.remove("option_greeks");
1883 if entry.is_empty() {
1884 m.remove(&instrument_id);
1885 should_unsubscribe = true;
1886 } else {
1887 should_unsubscribe = false;
1888 }
1889 } else {
1890 should_unsubscribe = false;
1891 }
1892 });
1893
1894 if should_unsubscribe {
1895 let product_type = self
1896 .get_product_type_for_instrument(instrument_id)
1897 .unwrap_or(BybitProductType::Option);
1898
1899 let ws = self
1900 .get_ws_client_for_product(product_type)
1901 .context("no WebSocket client for product type")?
1902 .clone();
1903
1904 self.spawn_ws(
1905 async move {
1906 ws.unsubscribe_ticker(instrument_id)
1907 .await
1908 .context("ticker unsubscribe for option greeks")
1909 },
1910 "option greeks unsubscribe",
1911 );
1912 }
1913 Ok(())
1914 }
1915
1916 fn subscribe_instruments(&mut self, cmd: SubscribeInstruments) -> anyhow::Result<()> {
1917 log::debug!(
1918 "subscribe_instruments: {venue} (definition updates detected via periodic instrument info polling)",
1919 venue = cmd.venue,
1920 );
1921 self.subscribe_all_instruments
1922 .store(true, Ordering::Relaxed);
1923 Ok(())
1924 }
1925
1926 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
1927 log::debug!(
1928 "subscribe_instrument: {id} (definition updates detected via periodic instrument info polling)",
1929 id = cmd.instrument_id,
1930 );
1931 self.instrument_subs.insert(cmd.instrument_id);
1932 Ok(())
1933 }
1934
1935 fn unsubscribe_instruments(&mut self, cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
1936 log::debug!("unsubscribe_instruments: {venue}", venue = cmd.venue);
1937 self.subscribe_all_instruments
1938 .store(false, Ordering::Relaxed);
1939 Ok(())
1940 }
1941
1942 fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1943 log::debug!("unsubscribe_instrument: {id}", id = cmd.instrument_id);
1944 self.instrument_subs.remove(&cmd.instrument_id);
1945 Ok(())
1946 }
1947
1948 fn subscribe_instrument_status(
1949 &mut self,
1950 cmd: SubscribeInstrumentStatus,
1951 ) -> anyhow::Result<()> {
1952 log::debug!(
1953 "subscribe_instrument_status: {id} (status changes detected via periodic instrument info polling)",
1954 id = cmd.instrument_id,
1955 );
1956 self.instrument_status_subs.insert(cmd.instrument_id);
1957
1958 if let Some(action) = self.status_cache.load().get(&cmd.instrument_id).copied() {
1959 let ts = self.clock.get_time_ns();
1960 emit_status(&self.data_sender, cmd.instrument_id, action, ts, ts);
1961 }
1962
1963 Ok(())
1964 }
1965
1966 fn unsubscribe_instrument_status(
1967 &mut self,
1968 cmd: &UnsubscribeInstrumentStatus,
1969 ) -> anyhow::Result<()> {
1970 log::debug!(
1971 "unsubscribe_instrument_status: {id}",
1972 id = cmd.instrument_id,
1973 );
1974 self.instrument_status_subs.remove(&cmd.instrument_id);
1975 Ok(())
1976 }
1977
1978 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1979 let http = self.http_client.clone();
1980 let sender = self.data_sender.clone();
1981 let instruments_cache = self.instruments.clone();
1982 let request_id = request.request_id;
1983 let client_id = request.client_id.unwrap_or(self.client_id);
1984 let venue = self.venue();
1985 let start = request.start;
1986 let end = request.end;
1987 let params = request.params;
1988 let clock = self.clock;
1989 let start_nanos = datetime_to_unix_nanos(start);
1990 let end_nanos = datetime_to_unix_nanos(end);
1991 let product_types = if self.config.product_types.is_empty() {
1992 vec![BybitProductType::Linear]
1993 } else {
1994 self.config.product_types.clone()
1995 };
1996
1997 self.spawn_command(async move {
1998 let mut all_instruments = Vec::new();
1999
2000 for product_type in product_types {
2001 match http.request_instruments(product_type, None, None).await {
2002 Ok(instruments) => {
2003 for instrument in instruments {
2004 upsert_instrument(&instruments_cache, instrument.clone());
2005 all_instruments.push(instrument);
2006 }
2007 }
2008 Err(e) => {
2009 log::error!("Failed to fetch instruments for {product_type:?}: {e:?}");
2010 }
2011 }
2012 }
2013
2014 let response = DataResponse::Instruments(InstrumentsResponse::new(
2015 request_id,
2016 client_id,
2017 venue,
2018 all_instruments,
2019 start_nanos,
2020 end_nanos,
2021 clock.get_time_ns(),
2022 params,
2023 ));
2024
2025 if let Err(e) = sender.send(DataEvent::Response(response)) {
2026 log::error!("Failed to send instruments response: {e}");
2027 }
2028 });
2029
2030 Ok(())
2031 }
2032
2033 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
2034 let http = self.http_client.clone();
2035 let sender = self.data_sender.clone();
2036 let instruments = self.instruments.clone();
2037 let instrument_id = request.instrument_id;
2038 let request_id = request.request_id;
2039 let client_id = request.client_id.unwrap_or(self.client_id);
2040 let start = request.start;
2041 let end = request.end;
2042 let params = request.params;
2043 let clock = self.clock;
2044 let start_nanos = datetime_to_unix_nanos(start);
2045 let end_nanos = datetime_to_unix_nanos(end);
2046
2047 let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
2048 .unwrap_or(BybitProductType::Linear);
2049 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str()).to_string();
2050
2051 self.spawn_command(async move {
2052 match http
2053 .request_instruments(product_type, Some(raw_symbol), None)
2054 .await
2055 .context("fetch instrument from API")
2056 {
2057 Ok(fetched) => {
2058 if let Some(instrument) = fetched.into_iter().find(|i| i.id() == instrument_id)
2059 {
2060 upsert_instrument(&instruments, instrument.clone());
2061
2062 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
2063 request_id,
2064 client_id,
2065 instrument.id(),
2066 instrument,
2067 start_nanos,
2068 end_nanos,
2069 clock.get_time_ns(),
2070 params,
2071 )));
2072
2073 if let Err(e) = sender.send(DataEvent::Response(response)) {
2074 log::error!("Failed to send instrument response: {e}");
2075 }
2076 } else {
2077 log::error!("Instrument not found: {instrument_id}");
2078 }
2079 }
2080 Err(e) => log::error!("Instrument request failed: {e:?}"),
2081 }
2082 });
2083
2084 Ok(())
2085 }
2086
2087 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
2088 let http = self.http_client.clone();
2089 let sender = self.data_sender.clone();
2090 let instrument_id = request.instrument_id;
2091 let depth = request.depth.map(|n| n.get() as u32);
2092 let request_id = request.request_id;
2093 let client_id = request.client_id.unwrap_or(self.client_id);
2094 let params = request.params;
2095 let clock = self.clock;
2096
2097 let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
2098 .unwrap_or(BybitProductType::Linear);
2099
2100 self.spawn_command(async move {
2101 match http
2102 .request_orderbook_snapshot(product_type, instrument_id, depth)
2103 .await
2104 .context("failed to request book snapshot from Bybit")
2105 {
2106 Ok(deltas) => {
2107 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2108 if let Err(e) = book.apply_deltas(&deltas) {
2109 log::error!("Failed to apply book deltas for {instrument_id}: {e}");
2110 return;
2111 }
2112
2113 let response = DataResponse::Book(BookResponse::new(
2114 request_id,
2115 client_id,
2116 instrument_id,
2117 book,
2118 None,
2119 None,
2120 clock.get_time_ns(),
2121 params,
2122 ));
2123
2124 if let Err(e) = sender.send(DataEvent::Response(response)) {
2125 log::error!("Failed to send book snapshot response: {e}");
2126 }
2127 }
2128 Err(e) => log::error!("Book snapshot request failed for {instrument_id}: {e:?}"),
2129 }
2130 });
2131
2132 Ok(())
2133 }
2134
2135 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
2136 let http = self.http_client.clone();
2137 let sender = self.data_sender.clone();
2138 let instrument_id = request.instrument_id;
2139 let start = request.start;
2140 let end = request.end;
2141 let limit = request.limit.map(|n| n.get() as u32);
2142 let request_id = request.request_id;
2143 let client_id = request.client_id.unwrap_or(self.client_id);
2144 let params = request.params;
2145 let clock = self.clock;
2146 let start_nanos = datetime_to_unix_nanos(start);
2147 let end_nanos = datetime_to_unix_nanos(end);
2148
2149 let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
2150 .unwrap_or(BybitProductType::Linear);
2151
2152 self.spawn_command(async move {
2153 match http
2154 .request_trades(product_type, instrument_id, limit)
2155 .await
2156 .context("failed to request trades from Bybit")
2157 {
2158 Ok(trades) => {
2159 let response = DataResponse::Trades(TradesResponse::new(
2160 request_id,
2161 client_id,
2162 instrument_id,
2163 trades,
2164 start_nanos,
2165 end_nanos,
2166 clock.get_time_ns(),
2167 params,
2168 ));
2169
2170 if let Err(e) = sender.send(DataEvent::Response(response)) {
2171 log::error!("Failed to send trades response: {e}");
2172 }
2173 }
2174 Err(e) => log::error!("Trade request failed: {e:?}"),
2175 }
2176 });
2177
2178 Ok(())
2179 }
2180
2181 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
2182 let http = self.http_client.clone();
2183 let bars_timestamp_on_close = self.config.bars_timestamp_on_close;
2184 let sender = self.data_sender.clone();
2185 let bar_type = request.bar_type;
2186 let start = request.start;
2187 let end = request.end;
2188 let limit = request.limit.map(|n| n.get() as u32);
2189 let request_id = request.request_id;
2190 let client_id = request.client_id.unwrap_or(self.client_id);
2191 let params = request.params;
2192 let clock = self.clock;
2193 let start_nanos = datetime_to_unix_nanos(start);
2194 let end_nanos = datetime_to_unix_nanos(end);
2195
2196 let instrument_id = bar_type.instrument_id();
2197 let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
2198 .unwrap_or(BybitProductType::Linear);
2199
2200 self.spawn_command(async move {
2201 match http
2202 .request_bars(
2203 product_type,
2204 bar_type,
2205 start,
2206 end,
2207 limit,
2208 bars_timestamp_on_close,
2209 )
2210 .await
2211 .context("failed to request bars from Bybit")
2212 {
2213 Ok(bars) => {
2214 let response = DataResponse::Bars(BarsResponse::new(
2215 request_id,
2216 client_id,
2217 bar_type,
2218 bars,
2219 start_nanos,
2220 end_nanos,
2221 clock.get_time_ns(),
2222 params,
2223 ));
2224
2225 if let Err(e) = sender.send(DataEvent::Response(response)) {
2226 log::error!("Failed to send bars response: {e}");
2227 }
2228 }
2229 Err(e) => log::error!("Bar request failed: {e:?}"),
2230 }
2231 });
2232
2233 Ok(())
2234 }
2235
2236 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
2237 let http = self.http_client.clone();
2238 let sender = self.data_sender.clone();
2239 let instrument_id = request.instrument_id;
2240 let start = request.start;
2241 let end = request.end;
2242 let limit = request.limit.map(|n| n.get() as u32);
2243 let request_id = request.request_id;
2244 let client_id = request.client_id.unwrap_or(self.client_id);
2245 let params = request.params;
2246 let clock = self.clock;
2247 let start_nanos = datetime_to_unix_nanos(start);
2248 let end_nanos = datetime_to_unix_nanos(end);
2249
2250 let product_type = BybitProductType::from_suffix(instrument_id.symbol.as_str())
2251 .unwrap_or(BybitProductType::Linear);
2252
2253 if product_type == BybitProductType::Spot || product_type == BybitProductType::Option {
2254 anyhow::bail!("Funding rates not available for {product_type} instruments");
2255 }
2256
2257 self.spawn_command(async move {
2258 match http
2259 .request_funding_rates(product_type, instrument_id, start, end, limit)
2260 .await
2261 .context("failed to request funding rates from Bybit")
2262 {
2263 Ok(funding_rates) => {
2264 let response = DataResponse::FundingRates(FundingRatesResponse::new(
2265 request_id,
2266 client_id,
2267 instrument_id,
2268 funding_rates,
2269 start_nanos,
2270 end_nanos,
2271 clock.get_time_ns(),
2272 params,
2273 ));
2274
2275 if let Err(e) = sender.send(DataEvent::Response(response)) {
2276 log::error!("Failed to send funding rates response: {e}");
2277 }
2278 }
2279 Err(e) => log::error!("Funding rates request failed for {instrument_id}: {e:?}"),
2280 }
2281 });
2282
2283 Ok(())
2284 }
2285
2286 fn request_option_chain_reference_price(
2287 &self,
2288 request: RequestOptionChainReferencePrice,
2289 ) -> anyhow::Result<()> {
2290 let series_id = request.series_id;
2291 let instrument_id = request.instrument_id;
2292 let http_client = self.http_client.clone();
2293 let sender = self.data_sender.clone();
2294 let request_id = request.request_id;
2295 let client_id = request.client_id.unwrap_or(self.client_id());
2296 let params = request.params;
2297 let clock = self.clock;
2298
2299 self.spawn_command(async move {
2300 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str()).to_string();
2301 let query = crate::http::query::BybitTickersParams {
2302 category: BybitProductType::Option,
2303 symbol: Some(raw_symbol),
2304 base_coin: None,
2305 exp_date: None,
2306 };
2307 let price = match http_client.request_option_tickers_raw_with_params(&query).await {
2308 Ok(tickers) => tickers.into_iter().find_map(|ticker| {
2309 let decimal = ticker.underlying_price.parse::<Decimal>().ok()?;
2310 if decimal <= Decimal::ZERO {
2311 return None;
2312 }
2313
2314 match Price::from_decimal(decimal) {
2315 Ok(price) => Some(price),
2316 Err(e) => {
2317 log::warn!(
2318 "Invalid Bybit option-chain reference price for {instrument_id}: {e}"
2319 );
2320 None
2321 }
2322 }
2323 }),
2324 Err(e) => {
2325 log::error!(
2326 "Option-chain reference price request failed for {series_id}: {e:?}"
2327 );
2328 None
2329 }
2330 };
2331 let response = DataResponse::OptionChainReferencePrice(
2332 OptionChainReferencePriceResponse::new(
2333 request_id,
2334 client_id,
2335 series_id,
2336 price,
2337 clock.get_time_ns(),
2338 params,
2339 ),
2340 );
2341
2342 if let Err(e) = sender.send(DataEvent::Response(response)) {
2343 log::error!("Failed to send option-chain reference price response: {e}");
2344 }
2345 });
2346
2347 Ok(())
2348 }
2349}
2350
2351#[cfg(test)]
2352#[path = "../benches"]
2353pub(crate) mod tests {
2354 #[allow(dead_code)]
2356 #[path = "quotes/mod.rs"]
2357 pub(crate) mod benchmarks;
2358
2359 use std::{sync::Arc, time::Duration};
2360
2361 use ahash::{AHashMap, AHashSet};
2362 use dashmap::DashMap;
2363 use nautilus_common::{
2364 clients::DataClient,
2365 live::{dst::time::Instant, runner::set_data_event_sender},
2366 messages::{
2367 DataEvent,
2368 data::{
2369 SubscribeBookDeltas, SubscribeCustomData, SubscribeQuotes, UnsubscribeBookDeltas,
2370 UnsubscribeCustomData, UnsubscribeQuotes,
2371 },
2372 },
2373 testing::wait_until_async,
2374 };
2375 use nautilus_core::{
2376 AtomicMap, AtomicSet, Params, UUID4, UnixNanos,
2377 time::{AtomicTime, get_atomic_clock_realtime},
2378 };
2379 use nautilus_live::{
2380 book::snapshot::SnapshotGate,
2381 task::{TaskGroup, TaskSpawner},
2382 };
2383 use nautilus_model::{
2384 data::{BarType, CustomData, Data, DataType, MarkPriceUpdate, QuoteTick},
2385 enums::{AggressorSide, BookAction, BookType, PositionSide},
2386 identifiers::InstrumentId,
2387 instruments::{Instrument, InstrumentAny},
2388 types::{Price, Quantity},
2389 };
2390 use rstest::rstest;
2391 use ustr::Ustr;
2392
2393 use super::{
2394 BybitDataClient, handle_orderbook_deltas, handle_ws_message, remove_owned_liquidation_sub,
2395 validate_orderbook_depth,
2396 };
2397 use crate::{
2398 book::{BookSequenceOutcome, sync::BookSyncTracker},
2399 common::{
2400 consts::BYBIT_CLIENT_ID,
2401 enums::BybitProductType,
2402 parse::{parse_linear_instrument, parse_option_instrument},
2403 rate_limit::BYBIT_OPTION_SUBSCRIPTION_LIMIT,
2404 testing::load_test_json,
2405 },
2406 config::BybitDataClientConfig,
2407 data_types::BybitLiquidation,
2408 http::models::{BybitInstrumentLinearResponse, BybitInstrumentOptionResponse},
2409 websocket::{
2410 client::BybitWebSocketClient,
2411 messages::{
2412 BybitWsKlineMsg, BybitWsLiquidationMsg, BybitWsMessage, BybitWsOrderbookDepthMsg,
2413 BybitWsTickerLinearMsg, BybitWsTickerOptionMsg, BybitWsTradeMsg,
2414 },
2415 },
2416 };
2417
2418 fn linear_instrument() -> InstrumentAny {
2419 let json = load_test_json("http_get_instruments_linear.json");
2420 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2421 let instrument = &response.result.list[0];
2422 let ts = UnixNanos::new(1_700_000_000_000_000_000);
2423 parse_linear_instrument(instrument, ts, ts).unwrap()
2424 }
2425
2426 fn linear_instruments() -> Vec<InstrumentAny> {
2427 let json = load_test_json("http_get_instruments_linear.json");
2428 let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
2429 let ts = UnixNanos::new(1_700_000_000_000_000_000);
2430 response
2431 .result
2432 .list
2433 .iter()
2434 .map(|instrument| parse_linear_instrument(instrument, ts, ts).unwrap())
2435 .collect()
2436 }
2437
2438 fn option_instrument() -> InstrumentAny {
2439 let json = load_test_json("http_get_instruments_option.json");
2440 let response: BybitInstrumentOptionResponse = serde_json::from_str(&json).unwrap();
2441 let instrument = &response.result.list[0];
2442 let ts = UnixNanos::new(1_700_000_000_000_000_000);
2443 parse_option_instrument(instrument, ts, ts).unwrap()
2444 }
2445
2446 fn build_instruments(instruments: &[InstrumentAny]) -> AHashMap<Ustr, InstrumentAny> {
2447 let mut map = AHashMap::new();
2448 for inst in instruments {
2449 map.insert(inst.id().symbol.inner(), inst.clone());
2450 }
2451 map
2452 }
2453
2454 #[expect(clippy::type_complexity)]
2455 fn empty_subs() -> (
2456 Arc<AtomicSet<InstrumentId>>,
2457 Arc<AtomicMap<InstrumentId, AHashSet<&'static str>>>,
2458 Arc<AtomicSet<InstrumentId>>,
2459 Arc<AtomicMap<InstrumentId, u32>>,
2460 Arc<AtomicSet<InstrumentId>>,
2461 Arc<AtomicMap<String, BarType>>,
2462 ) {
2463 (
2464 Arc::new(AtomicSet::new()),
2465 Arc::new(AtomicMap::new()),
2466 Arc::new(AtomicSet::new()),
2467 Arc::new(AtomicMap::new()),
2468 Arc::new(AtomicSet::new()),
2469 Arc::new(AtomicMap::new()),
2470 )
2471 }
2472
2473 fn book_context() -> (BookSyncTracker, BybitWebSocketClient, TaskSpawner) {
2474 let tasks = TaskGroup::new().spawner().unwrap();
2475 let ws_client = BybitWebSocketClient::new_public(None, 20);
2476 (BookSyncTracker::default(), ws_client, tasks)
2477 }
2478
2479 fn liquidation_data_type(instrument_id: InstrumentId, identifier: Option<&str>) -> DataType {
2480 let mut metadata = Params::new();
2481 metadata.insert(
2482 "instrument_id".to_string(),
2483 serde_json::Value::String(instrument_id.to_string()),
2484 );
2485
2486 DataType::new(
2487 "BybitLiquidation",
2488 Some(metadata),
2489 identifier.map(ToString::to_string),
2490 )
2491 }
2492
2493 fn subscribe_custom_data(data_type: DataType) -> SubscribeCustomData {
2494 SubscribeCustomData::new(
2495 Some(*BYBIT_CLIENT_ID),
2496 None,
2497 data_type,
2498 UUID4::new(),
2499 UnixNanos::default(),
2500 None,
2501 None,
2502 )
2503 }
2504
2505 fn unsubscribe_custom_data(data_type: DataType) -> UnsubscribeCustomData {
2506 UnsubscribeCustomData::new(
2507 Some(*BYBIT_CLIENT_ID),
2508 None,
2509 data_type,
2510 UUID4::new(),
2511 UnixNanos::default(),
2512 None,
2513 None,
2514 )
2515 }
2516
2517 fn handle_liquidation_message(
2518 msg: BybitWsLiquidationMsg,
2519 instruments: &AHashMap<Ustr, InstrumentAny>,
2520 liquidation_subs: &Arc<AtomicMap<InstrumentId, Arc<DataType>>>,
2521 ts_init: UnixNanos,
2522 ) -> Vec<CustomData> {
2523 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2524 empty_subs();
2525 let (book_sync, ws_client, tasks) = book_context();
2526
2527 let mut funding_cache = AHashMap::new();
2528 let clock = AtomicTime::new(false, ts_init);
2529 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2530
2531 handle_ws_message(
2532 &BybitWsMessage::Liquidation(msg),
2533 &tx.into(),
2534 instruments,
2535 Some(BybitProductType::Linear),
2536 &trade_subs,
2537 &ticker_subs,
2538 "e_subs,
2539 &book_depths,
2540 &book_sync,
2541 &ws_client,
2542 &greeks_subs,
2543 liquidation_subs,
2544 &bar_types,
2545 true,
2546 &DashMap::new(),
2547 &mut funding_cache,
2548 Duration::ZERO,
2549 &tasks,
2550 &clock,
2551 );
2552
2553 let mut events = Vec::new();
2554
2555 while let Ok(event) = rx.try_recv() {
2556 match event {
2557 DataEvent::Data(Data::Custom(custom)) => events.push(custom),
2558 other => panic!("Expected custom data event, found {other:?}"),
2559 }
2560 }
2561
2562 events
2563 }
2564
2565 fn as_liquidation(custom: &CustomData) -> &BybitLiquidation {
2566 custom
2567 .data
2568 .as_any()
2569 .downcast_ref::<BybitLiquidation>()
2570 .expect("expected BybitLiquidation")
2571 }
2572
2573 #[tokio::test]
2574 async fn test_failed_book_subscription_releases_registration() {
2575 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2576 set_data_event_sender(tx);
2577 let config = BybitDataClientConfig {
2578 product_types: vec![BybitProductType::Option],
2579 ..Default::default()
2580 };
2581 let mut client = BybitDataClient::new(*BYBIT_CLIENT_ID, config).unwrap();
2582 let ws = client.ws_clients[0].clone();
2583 let topics = (0..BYBIT_OPTION_SUBSCRIPTION_LIMIT)
2584 .map(|index| format!("tickers.OPTION-{index}"))
2585 .collect();
2586
2587 ws.subscribe(topics).await.unwrap_err();
2589 let instrument_id = InstrumentId::from("ETH-26JUN26-16000-P-OPTION.BYBIT");
2590 let expected_error = format!(
2591 "Client error: Option WebSocket subscription limit is {BYBIT_OPTION_SUBSCRIPTION_LIMIT} arguments per connection, requested {}",
2592 BYBIT_OPTION_SUBSCRIPTION_LIMIT + 1,
2593 );
2594 assert_eq!(
2595 ws.subscribe_orderbook(instrument_id, 50)
2596 .await
2597 .unwrap_err()
2598 .to_string(),
2599 expected_error,
2600 );
2601 client
2602 .instruments
2603 .insert(instrument_id, option_instrument());
2604 let command = SubscribeBookDeltas::new(
2605 instrument_id,
2606 BookType::L2_MBP,
2607 Some(*BYBIT_CLIENT_ID),
2608 None,
2609 UUID4::new(),
2610 UnixNanos::default(),
2611 None,
2612 false,
2613 None,
2614 None,
2615 );
2616
2617 for _ in 0..2 {
2618 client.subscribe_book_deltas(command.clone()).unwrap();
2619 wait_until_async(
2620 || async { !client.book_depths.contains_key(&instrument_id) },
2621 Duration::from_secs(2),
2622 )
2623 .await;
2624 assert!(!client.book_depths.contains_key(&instrument_id));
2625 assert_eq!(
2626 ws.subscribe_orderbook(instrument_id, 50)
2627 .await
2628 .unwrap_err()
2629 .to_string(),
2630 expected_error,
2631 );
2632 }
2633 }
2634
2635 #[tokio::test]
2636 async fn test_failed_book_subscription_releases_transport_reference() {
2637 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2638 set_data_event_sender(tx);
2639 let mut client =
2640 BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
2641 let ws = client.ws_clients[0].clone();
2642 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2643 client
2644 .subscribe_book_deltas(SubscribeBookDeltas::new(
2645 instrument_id,
2646 BookType::L2_MBP,
2647 Some(*BYBIT_CLIENT_ID),
2648 None,
2649 UUID4::new(),
2650 UnixNanos::default(),
2651 None,
2652 false,
2653 None,
2654 None,
2655 ))
2656 .unwrap();
2657 wait_until_async(
2658 || async { !client.book_depths.contains_key(&instrument_id) },
2659 Duration::from_secs(2),
2660 )
2661 .await;
2662
2663 let error = ws.subscribe_orderbook(instrument_id, 50).await.unwrap_err();
2664
2665 assert_eq!(
2666 error.to_string(),
2667 "WebSocket send error: Failed to send subscribe command: channel closed",
2668 );
2669 assert!(!client.book_depths.contains_key(&instrument_id));
2670 }
2671
2672 #[tokio::test]
2673 async fn test_failed_liquidation_subscription_releases_registration() {
2674 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2675 set_data_event_sender(tx);
2676 let mut client =
2677 BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
2678 let ws = client.ws_clients[0].clone();
2679 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2680 let command = subscribe_custom_data(liquidation_data_type(instrument_id, None));
2681
2682 for _ in 0..2 {
2684 client.subscribe(command.clone()).unwrap();
2685 wait_until_async(
2686 || async { !client.liquidation_subs.contains_key(&instrument_id) },
2687 Duration::from_secs(2),
2688 )
2689 .await;
2690 }
2691
2692 let error = ws.subscribe_liquidations(instrument_id).await.unwrap_err();
2693
2694 assert_eq!(
2695 error.to_string(),
2696 "WebSocket send error: Failed to send subscribe command: channel closed",
2697 );
2698 assert!(!client.liquidation_subs.contains_key(&instrument_id));
2699 }
2700
2701 #[rstest]
2702 #[tokio::test]
2703 async fn test_failed_quote_subscription_releases_registration() {
2704 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2705 set_data_event_sender(tx);
2706 let mut client =
2707 BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
2708 let ws = client.ws_clients[0].clone();
2709 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2710 let command = SubscribeQuotes::new(
2711 instrument_id,
2712 Some(*BYBIT_CLIENT_ID),
2713 None,
2714 UUID4::new(),
2715 UnixNanos::default(),
2716 None,
2717 None,
2718 );
2719
2720 for _ in 0..2 {
2722 client.subscribe_quotes(command.clone()).unwrap();
2723 wait_until_async(
2724 || async { !client.quote_subs.contains(&instrument_id) },
2725 Duration::from_secs(2),
2726 )
2727 .await;
2728 }
2729
2730 let error = ws.subscribe_orderbook(instrument_id, 1).await.unwrap_err();
2731
2732 assert_eq!(
2733 error.to_string(),
2734 "WebSocket send error: Failed to send subscribe command: channel closed",
2735 );
2736 assert!(!client.quote_subs.contains(&instrument_id));
2737 }
2738
2739 #[rstest]
2740 fn test_liquidation_subscription_keeps_first_data_type() {
2741 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2742 set_data_event_sender(tx);
2743 let mut client =
2744 BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
2745 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2746 let data_type = liquidation_data_type(instrument_id, None);
2747 let other_data_type = liquidation_data_type(instrument_id, Some("other"));
2748 let subscription = Arc::new(data_type.clone());
2749 client
2750 .liquidation_subs
2751 .insert(instrument_id, Arc::clone(&subscription));
2752
2753 let error = client
2754 .subscribe(subscribe_custom_data(other_data_type.clone()))
2755 .unwrap_err();
2756 client
2757 .unsubscribe(&unsubscribe_custom_data(other_data_type))
2758 .unwrap();
2759 client
2760 .subscribe(subscribe_custom_data(data_type.clone()))
2761 .unwrap();
2762
2763 assert_eq!(
2764 error.to_string(),
2765 format!(
2766 "Already subscribed to liquidations for BTCUSDT-LINEAR.BYBIT as {}",
2767 data_type.topic()
2768 ),
2769 );
2770 assert!(
2771 client
2772 .liquidation_subs
2773 .get_cloned(&instrument_id)
2774 .is_some_and(|current| Arc::ptr_eq(¤t, &subscription))
2775 );
2776 }
2777
2778 #[rstest]
2779 fn test_remove_owned_liquidation_sub_removes_own_entry() {
2780 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2781 let owned = Arc::new(liquidation_data_type(instrument_id, None));
2782 let subs = AtomicMap::new();
2783 subs.insert(instrument_id, Arc::clone(&owned));
2784
2785 let removed = remove_owned_liquidation_sub(&subs, instrument_id, &owned);
2786
2787 assert!(removed);
2788 assert!(subs.is_empty());
2789 }
2790
2791 #[rstest]
2792 fn test_remove_owned_liquidation_sub_skips_entry_removed_by_unsubscribe() {
2793 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2794 let owned = Arc::new(liquidation_data_type(instrument_id, None));
2795 let subs = AtomicMap::new();
2796
2797 let removed = remove_owned_liquidation_sub(&subs, instrument_id, &owned);
2798
2799 assert!(!removed);
2800 assert!(subs.is_empty());
2801 }
2802
2803 #[rstest]
2804 #[case::identical_data_type(None)]
2805 #[case::other_data_type(Some("other"))]
2806 fn test_remove_owned_liquidation_sub_keeps_later_subscription(
2807 #[case] identifier: Option<&str>,
2808 ) {
2809 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2810 let owned = Arc::new(liquidation_data_type(instrument_id, None));
2811 let later = Arc::new(liquidation_data_type(instrument_id, identifier));
2812 let subs = AtomicMap::new();
2813 subs.insert(instrument_id, Arc::clone(&later));
2814
2815 let removed = remove_owned_liquidation_sub(&subs, instrument_id, &owned);
2816
2817 assert!(!removed);
2818 assert_eq!(subs.len(), 1);
2819 assert!(
2820 subs.get_cloned(&instrument_id)
2821 .is_some_and(|current| Arc::ptr_eq(¤t, &later))
2822 );
2823 }
2824
2825 #[rstest]
2826 fn test_validate_orderbook_depth_accepts_1000() {
2827 assert!(validate_orderbook_depth(1000).is_ok());
2828 }
2829
2830 #[rstest]
2831 fn test_validate_orderbook_depth_rejects_500() {
2832 let e = validate_orderbook_depth(500).unwrap_err();
2833
2834 assert_eq!(
2835 e.to_string(),
2836 "invalid depth 500; valid values are [1, 50, 200, 1000]"
2837 );
2838 }
2839
2840 #[rstest]
2841 #[case::open(false, true, Some(1_672_324_800_000_000_000))]
2842 #[case::close(true, true, Some(1_672_325_100_000_000_000))]
2843 #[case::unconfirmed_open(false, false, None)]
2844 #[case::unconfirmed_close(true, false, None)]
2845 fn test_handle_kline_bar_timestamp(
2846 #[case] on_close: bool,
2847 #[case] confirmed: bool,
2848 #[case] expected: Option<u64>,
2849 ) {
2850 let instrument = linear_instrument();
2851 let instruments = build_instruments(std::slice::from_ref(&instrument));
2852 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2853 empty_subs();
2854 let (book_sync, ws_client, tasks) = book_context();
2855 let bar_type = BarType::from("BTCUSDT-LINEAR.BYBIT-5-MINUTE-LAST-EXTERNAL");
2856 bar_types.insert("kline.5.BTCUSDT".to_string(), bar_type);
2857 let mut msg: BybitWsKlineMsg =
2858 serde_json::from_str(&load_test_json("ws_kline.json")).unwrap();
2859 msg.data[0].confirm = confirmed;
2860
2861 let mut funding_cache = AHashMap::new();
2862 let clock = get_atomic_clock_realtime();
2863 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2864 handle_ws_message(
2865 &BybitWsMessage::Kline(msg),
2866 &tx.into(),
2867 &instruments,
2868 Some(BybitProductType::Linear),
2869 &trade_subs,
2870 &ticker_subs,
2871 "e_subs,
2872 &book_depths,
2873 &book_sync,
2874 &ws_client,
2875 &greeks_subs,
2876 &Arc::new(AtomicMap::new()),
2877 &bar_types,
2878 on_close,
2879 &DashMap::new(),
2880 &mut funding_cache,
2881 Duration::ZERO,
2882 &tasks,
2883 clock,
2884 );
2885
2886 if let Some(expected) = expected {
2887 let DataEvent::Data(Data::Bar(bar)) = rx.try_recv().unwrap() else {
2888 panic!("Expected bar event");
2889 };
2890 assert_eq!(bar.bar_type, bar_type);
2891 assert_eq!(bar.ts_event, UnixNanos::from(expected));
2892 }
2893 assert!(rx.try_recv().is_err());
2894 }
2895
2896 #[rstest]
2897 fn test_handle_trade_message_emits_trade_tick() {
2898 let instrument = linear_instrument();
2899 let instruments = build_instruments(std::slice::from_ref(&instrument));
2900 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2901 empty_subs();
2902 let (book_sync, ws_client, tasks) = book_context();
2903 trade_subs.insert(instrument.id());
2904
2905 let mut funding_cache = AHashMap::new();
2906 let clock = get_atomic_clock_realtime();
2907
2908 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2909
2910 let json = load_test_json("ws_public_trade.json");
2911 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2912 let ws_msg = BybitWsMessage::Trade(msg);
2913
2914 handle_ws_message(
2915 &ws_msg,
2916 &tx.into(),
2917 &instruments,
2918 Some(BybitProductType::Linear),
2919 &trade_subs,
2920 &ticker_subs,
2921 "e_subs,
2922 &book_depths,
2923 &book_sync,
2924 &ws_client,
2925 &greeks_subs,
2926 &Arc::new(AtomicMap::new()),
2927 &bar_types,
2928 true,
2929 &DashMap::new(),
2930 &mut funding_cache,
2931 Duration::ZERO,
2932 &tasks,
2933 clock,
2934 );
2935
2936 let event = rx.try_recv().unwrap();
2937 match event {
2938 DataEvent::Data(Data::Trade(tick)) => {
2939 assert_eq!(tick.instrument_id, instrument.id());
2940 assert_eq!(tick.price, instrument.make_price(27451.00));
2941 assert_eq!(tick.size, instrument.make_qty(0.010, None));
2942 assert_eq!(tick.aggressor_side, AggressorSide::Buy);
2943 }
2944 other => panic!("Expected Trade data event, found {other:?}"),
2945 }
2946 }
2947
2948 #[rstest]
2949 fn test_handle_trade_message_unknown_symbol_no_event() {
2950 let instruments = AHashMap::new();
2951 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
2952 empty_subs();
2953 let (book_sync, ws_client, tasks) = book_context();
2954
2955 let mut funding_cache = AHashMap::new();
2956 let clock = get_atomic_clock_realtime();
2957
2958 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2959
2960 let json = load_test_json("ws_public_trade.json");
2961 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2962 let ws_msg = BybitWsMessage::Trade(msg);
2963
2964 handle_ws_message(
2965 &ws_msg,
2966 &tx.into(),
2967 &instruments,
2968 Some(BybitProductType::Linear),
2969 &trade_subs,
2970 &ticker_subs,
2971 "e_subs,
2972 &book_depths,
2973 &book_sync,
2974 &ws_client,
2975 &greeks_subs,
2976 &Arc::new(AtomicMap::new()),
2977 &bar_types,
2978 true,
2979 &DashMap::new(),
2980 &mut funding_cache,
2981 Duration::ZERO,
2982 &tasks,
2983 clock,
2984 );
2985
2986 rx.try_recv().unwrap_err();
2987 }
2988
2989 #[rstest]
2990 #[case::metadata_only(None)]
2991 #[case::with_identifier(Some("bybit-liquidations"))]
2992 fn test_handle_liquidation_message_emits_subscribed_data_type(
2993 #[case] identifier: Option<&str>,
2994 ) {
2995 let instrument = linear_instrument();
2996 let instrument_id = instrument.id();
2997 let instruments = build_instruments(&[instrument]);
2998 let data_type = liquidation_data_type(instrument_id, identifier);
2999 let liquidation_subs = Arc::new(AtomicMap::new());
3000 liquidation_subs.insert(instrument_id, Arc::new(data_type.clone()));
3001 let ts_init = UnixNanos::new(1_739_502_303_500_000_000);
3002 let msg: BybitWsLiquidationMsg =
3003 serde_json::from_str(&load_test_json("ws_all_liquidation.json")).unwrap();
3004
3005 let events = handle_liquidation_message(msg, &instruments, &liquidation_subs, ts_init);
3006
3007 assert_eq!(events.len(), 2);
3008 assert_eq!(events[0].data_type, data_type);
3009 assert_eq!(events[0].data_type.topic(), data_type.topic());
3010 assert_eq!(events[0].data_type.identifier(), identifier);
3011 assert_eq!(events[1].data_type, data_type);
3012 assert_eq!(
3013 as_liquidation(&events[0]),
3014 &BybitLiquidation::new(
3015 instrument_id,
3016 PositionSide::Long,
3017 Price::from("96250.5"),
3018 Quantity::from("0.015"),
3019 UnixNanos::new(1_739_502_302_929_000_000),
3020 ts_init,
3021 ),
3022 );
3023 assert_eq!(
3024 as_liquidation(&events[1]),
3025 &BybitLiquidation::new(
3026 instrument_id,
3027 PositionSide::Short,
3028 Price::from("97410.0"),
3029 Quantity::from("1.250"),
3030 UnixNanos::new(1_739_502_303_011_000_000),
3031 ts_init,
3032 ),
3033 );
3034 }
3035
3036 #[rstest]
3037 #[case::unsubscribed_instrument("ETHUSDT")]
3038 #[case::uncached_symbol("SOLUSDT")]
3039 fn test_handle_liquidation_message_skips_element(#[case] skipped_symbol: &str) {
3040 let instruments = linear_instruments();
3041 let instrument_id = instruments[0].id();
3042 let instruments = build_instruments(&instruments);
3043 let data_type = liquidation_data_type(instrument_id, None);
3044 let liquidation_subs = Arc::new(AtomicMap::new());
3045 liquidation_subs.insert(instrument_id, Arc::new(data_type.clone()));
3046 let ts_init = UnixNanos::new(1_739_502_303_500_000_000);
3047 let mut value: serde_json::Value =
3048 serde_json::from_str(&load_test_json("ws_all_liquidation.json")).unwrap();
3049 value["data"][1]["s"] = serde_json::json!(skipped_symbol);
3050 let msg: BybitWsLiquidationMsg = serde_json::from_value(value).unwrap();
3051
3052 let events = handle_liquidation_message(msg, &instruments, &liquidation_subs, ts_init);
3053
3054 assert_eq!(instruments.len(), 2);
3055 assert_eq!(events.len(), 1);
3056 assert_eq!(events[0].data_type, data_type);
3057 assert_eq!(
3058 as_liquidation(&events[0]),
3059 &BybitLiquidation::new(
3060 instrument_id,
3061 PositionSide::Long,
3062 Price::from("96250.5"),
3063 Quantity::from("0.015"),
3064 UnixNanos::new(1_739_502_302_929_000_000),
3065 ts_init,
3066 ),
3067 );
3068 }
3069
3070 #[rstest]
3071 fn test_handle_liquidation_message_skips_invalid_element() {
3072 let instrument = linear_instrument();
3073 let instrument_id = instrument.id();
3074 let instruments = build_instruments(&[instrument]);
3075 let data_type = liquidation_data_type(instrument_id, None);
3076 let liquidation_subs = Arc::new(AtomicMap::new());
3077 liquidation_subs.insert(instrument_id, Arc::new(data_type.clone()));
3078 let ts_init = UnixNanos::new(1_739_502_303_500_000_000);
3079 let mut value: serde_json::Value =
3080 serde_json::from_str(&load_test_json("ws_all_liquidation.json")).unwrap();
3081 value["data"][0]["S"] = serde_json::json!("");
3082 let msg: BybitWsLiquidationMsg = serde_json::from_value(value).unwrap();
3083
3084 let events = handle_liquidation_message(msg, &instruments, &liquidation_subs, ts_init);
3085
3086 assert_eq!(events.len(), 1);
3087 assert_eq!(events[0].data_type, data_type);
3088 assert_eq!(
3089 as_liquidation(&events[0]),
3090 &BybitLiquidation::new(
3091 instrument_id,
3092 PositionSide::Short,
3093 Price::from("97410.0"),
3094 Quantity::from("1.250"),
3095 UnixNanos::new(1_739_502_303_011_000_000),
3096 ts_init,
3097 ),
3098 );
3099 }
3100
3101 #[rstest]
3102 fn test_handle_liquidation_message_without_subscription_no_event() {
3103 let instrument = linear_instrument();
3104 let instruments = build_instruments(&[instrument]);
3105 let liquidation_subs = Arc::new(AtomicMap::new());
3106 let msg: BybitWsLiquidationMsg =
3107 serde_json::from_str(&load_test_json("ws_all_liquidation.json")).unwrap();
3108
3109 let events = handle_liquidation_message(
3110 msg,
3111 &instruments,
3112 &liquidation_subs,
3113 UnixNanos::new(1_739_502_303_500_000_000),
3114 );
3115
3116 assert!(events.is_empty());
3117 }
3118
3119 #[rstest]
3120 #[case::shared_depth_one("orderbook.1.BTCUSDT", 1, "snapshot", true, true)]
3121 #[case::depth_one_with_deeper_book("orderbook.1.BTCUSDT", 50, "snapshot", false, true)]
3122 #[case::deeper_snapshot("orderbook.50.BTCUSDT", 50, "snapshot", true, false)]
3123 #[case::deeper_deletion("orderbook.50.BTCUSDT", 50, "delta", true, false)]
3124 #[case::unsubscribed_depth("orderbook.200.BTCUSDT", 50, "delta", false, false)]
3125 #[case::invalid_topic("orderbook.invalid.BTCUSDT", 1, "snapshot", false, false)]
3126 #[case::wrong_symbol("orderbook.1.ETHUSDT", 1, "snapshot", false, false)]
3127 fn test_handle_orderbook_routes_by_depth(
3128 #[case] topic: &str,
3129 #[case] book_depth: u32,
3130 #[case] msg_type: &str,
3131 #[case] expect_book: bool,
3132 #[case] expect_quote: bool,
3133 ) {
3134 let instrument = linear_instrument();
3135 let instrument_id = instrument.id();
3136 let instruments = build_instruments(&[instrument]);
3137 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3138 empty_subs();
3139 let (book_sync, ws_client, tasks) = book_context();
3140 book_depths.insert(instrument_id, book_depth);
3141 book_sync.record_subscription(instrument_id, Instant::now(), SnapshotGate::default());
3142
3143 let synced = book_sync.validate(instrument_id, true, 123_456_789, Instant::now());
3145 assert_eq!(synced, BookSequenceOutcome::Accept);
3146 quote_subs.insert(instrument_id);
3147
3148 let mut funding_cache = AHashMap::new();
3149 let ts_init = UnixNanos::new(1_709_891_680_000_000_000);
3150 let clock = AtomicTime::new(false, ts_init);
3151 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3152 let fixture = if msg_type == "snapshot" {
3153 "ws_orderbook_snapshot.json"
3154 } else {
3155 "ws_orderbook_delta.json"
3156 };
3157 let mut msg: BybitWsOrderbookDepthMsg =
3158 serde_json::from_str(&load_test_json(fixture)).unwrap();
3159 msg.topic = topic.into();
3160
3161 handle_ws_message(
3162 &BybitWsMessage::Orderbook(msg),
3163 &tx.into(),
3164 &instruments,
3165 Some(BybitProductType::Linear),
3166 &trade_subs,
3167 &ticker_subs,
3168 "e_subs,
3169 &book_depths,
3170 &book_sync,
3171 &ws_client,
3172 &greeks_subs,
3173 &Arc::new(AtomicMap::new()),
3174 &bar_types,
3175 true,
3176 &DashMap::new(),
3177 &mut funding_cache,
3178 Duration::ZERO,
3179 &tasks,
3180 &clock,
3181 );
3182
3183 if expect_book {
3184 let DataEvent::Data(Data::BookDeltas(deltas)) = rx.try_recv().unwrap() else {
3185 panic!("Expected book deltas");
3186 };
3187 assert_eq!(deltas.instrument_id, instrument_id);
3188 assert_eq!(
3189 deltas.deltas[0].action == BookAction::Clear,
3190 msg_type == "snapshot"
3191 );
3192
3193 if msg_type == "delta" {
3194 assert_eq!(deltas.deltas.len(), 2);
3195 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
3196 assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.000"));
3197 }
3198 }
3199
3200 if expect_quote {
3201 let expected = QuoteTick::new(
3202 instrument_id,
3203 Price::from("27450.00"),
3204 Price::from("27451.00"),
3205 Quantity::from("0.500"),
3206 Quantity::from("0.750"),
3207 UnixNanos::new(1_709_891_679_000_000_000),
3208 ts_init,
3209 );
3210 let DataEvent::Data(Data::Quote(quote)) = rx.try_recv().unwrap() else {
3211 panic!("Expected quote");
3212 };
3213 assert_eq!(quote, expected);
3214 }
3215
3216 rx.try_recv().unwrap_err();
3217 }
3218
3219 fn orderbook_msg(msg_type: &str, update_id: i64, levels: bool) -> BybitWsOrderbookDepthMsg {
3220 let fixture = if msg_type == "snapshot" {
3221 "ws_orderbook_snapshot.json"
3222 } else {
3223 "ws_orderbook_delta.json"
3224 };
3225
3226 let mut msg: BybitWsOrderbookDepthMsg =
3227 serde_json::from_str(&load_test_json(fixture)).unwrap();
3228 msg.topic = "orderbook.50.BTCUSDT".into();
3229 msg.data.u = update_id;
3230
3231 if !levels {
3232 msg.data.b.clear();
3233 msg.data.a.clear();
3234 }
3235
3236 msg
3237 }
3238
3239 #[rstest]
3240 fn test_handle_orderbook_delta_without_levels_advances_book() {
3241 let instrument = linear_instrument();
3242 let instrument_id = instrument.id();
3243 let (_, _, _, book_depths, _, _) = empty_subs();
3244 let (book_sync, ws_client, tasks) = book_context();
3245 book_depths.insert(instrument_id, 50);
3246 book_sync.record_subscription(instrument_id, Instant::now(), SnapshotGate::default());
3247 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3248 let sender = tx.into();
3249 let ts_init = UnixNanos::new(1);
3250
3251 for msg in [
3252 orderbook_msg("snapshot", 100, true),
3253 orderbook_msg("delta", 101, false),
3254 orderbook_msg("delta", 102, true),
3255 ] {
3256 handle_orderbook_deltas(
3257 &msg,
3258 &instrument,
3259 &sender,
3260 &book_depths,
3261 &book_sync,
3262 &ws_client,
3263 Duration::ZERO,
3264 &tasks,
3265 ts_init,
3266 );
3267 }
3268
3269 let mut emitted = Vec::new();
3270
3271 while let Ok(DataEvent::Data(Data::BookDeltas(deltas))) = rx.try_recv() {
3272 emitted.push(deltas.deltas[0].action);
3273 }
3274
3275 assert_eq!(emitted, [BookAction::Clear, BookAction::Update]);
3276 }
3277
3278 #[rstest]
3279 #[case::gap(102, "27450.00")]
3280 #[case::malformed_level(101, "not-a-price")]
3281 #[case::negative_update_id(-1, "27450.00")]
3282 #[tokio::test]
3283 async fn test_handle_orderbook_fault_suppresses_output_and_claims_recovery(
3284 #[case] update_id: i64,
3285 #[case] bid_price: &str,
3286 ) {
3287 let instrument = linear_instrument();
3288 let instrument_id = instrument.id();
3289 let (_, _, _, book_depths, _, _) = empty_subs();
3290 let book_sync = BookSyncTracker::default();
3291 let ws_client = BybitWebSocketClient::new_public(None, 20);
3292 let tasks = TaskGroup::new();
3293 let spawner = tasks.spawner().unwrap();
3294 book_depths.insert(instrument_id, 50);
3295 book_sync.record_subscription(instrument_id, Instant::now(), SnapshotGate::default());
3296 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3297 let sender = tx.into();
3298 let mut fault = orderbook_msg("delta", update_id, true);
3299 fault.data.b[0][0] = bid_price.to_string();
3300
3301 for msg in [orderbook_msg("snapshot", 100, true), fault] {
3302 handle_orderbook_deltas(
3303 &msg,
3304 &instrument,
3305 &sender,
3306 &book_depths,
3307 &book_sync,
3308 &ws_client,
3309 Duration::from_secs(1),
3310 &spawner,
3311 UnixNanos::new(1),
3312 );
3313 }
3314
3315 let DataEvent::Data(Data::BookDeltas(snapshot)) = rx.try_recv().unwrap() else {
3316 panic!("Expected book snapshot");
3317 };
3318
3319 assert_eq!(snapshot.deltas[0].action, BookAction::Clear);
3320 rx.try_recv().unwrap_err();
3321 assert!(book_sync.claim_recovery(instrument_id).is_none());
3322 tasks.begin_shutdown();
3323 }
3324
3325 #[tokio::test]
3326 async fn test_handle_reconnected_resyncs_books_of_reconnected_socket() {
3327 let instrument = linear_instrument();
3328 let instrument_id = instrument.id();
3329 let instruments = build_instruments(std::slice::from_ref(&instrument));
3330 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3331 empty_subs();
3332 let book_sync = BookSyncTracker::default();
3333 let ws_client = BybitWebSocketClient::new_public(None, 20);
3334 let tasks = TaskGroup::new();
3335 let spawner = tasks.spawner().unwrap();
3336 book_depths.insert(instrument_id, 50);
3337 book_sync.record_subscription(instrument_id, Instant::now(), SnapshotGate::default());
3338 let synced = book_sync.validate(instrument_id, true, 100, Instant::now());
3339 assert_eq!(synced, BookSequenceOutcome::Accept);
3340
3341 let mut funding_cache = AHashMap::new();
3342 let clock = get_atomic_clock_realtime();
3343 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3344 let sender = tx.into();
3345 let snapshot_timeout = Duration::from_secs(10);
3346
3347 for message in [
3349 BybitWsMessage::Reconnected,
3350 BybitWsMessage::Orderbook(orderbook_msg("delta", 101, true)),
3351 ] {
3352 handle_ws_message(
3353 &message,
3354 &sender,
3355 &instruments,
3356 Some(BybitProductType::Linear),
3357 &trade_subs,
3358 &ticker_subs,
3359 "e_subs,
3360 &book_depths,
3361 &book_sync,
3362 &ws_client,
3363 &greeks_subs,
3364 &Arc::new(AtomicMap::new()),
3365 &bar_types,
3366 true,
3367 &DashMap::new(),
3368 &mut funding_cache,
3369 snapshot_timeout,
3370 &spawner,
3371 clock,
3372 );
3373 }
3374
3375 let deadline = Instant::now() + snapshot_timeout;
3376 let expired = book_sync.take_expired_snapshots(BybitProductType::Linear, deadline);
3377
3378 rx.try_recv().unwrap_err();
3379 assert_eq!(expired.len(), 1);
3380 assert_eq!(expired[0].instrument_id, instrument_id);
3381 tasks.begin_shutdown();
3382 }
3383
3384 #[rstest]
3385 #[case::unsubscribe(false)]
3386 #[case::reset(true)]
3387 #[tokio::test]
3388 async fn test_book_removal_cancels_running_recovery(#[case] reset: bool) {
3389 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3390 set_data_event_sender(tx);
3391 let mut client =
3392 BybitDataClient::new(*BYBIT_CLIENT_ID, BybitDataClientConfig::default()).unwrap();
3393 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3394 client.book_depths.insert(instrument_id, 50);
3395 client.book_sync.record_subscription(
3396 instrument_id,
3397 Instant::now(),
3398 SnapshotGate::default(),
3399 );
3400 let recovery = client.book_sync.claim_recovery(instrument_id).unwrap();
3401
3402 if reset {
3403 client.reset().unwrap();
3404 } else {
3405 let command = UnsubscribeBookDeltas::new(
3406 instrument_id,
3407 Some(*BYBIT_CLIENT_ID),
3408 None,
3409 UUID4::new(),
3410 UnixNanos::default(),
3411 None,
3412 None,
3413 );
3414 client.unsubscribe_book_deltas(&command).unwrap();
3415 }
3416
3417 assert!(recovery.cancellation.is_cancelled());
3418 assert!(!client.book_depths.contains_key(&instrument_id));
3419 }
3420
3421 #[rstest]
3422 fn test_handle_orderbook_message_no_sub_no_event() {
3423 let instrument = linear_instrument();
3424 let instruments = build_instruments(&[instrument]);
3425 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3426 empty_subs();
3427 let (book_sync, ws_client, tasks) = book_context();
3428
3429 let mut funding_cache = AHashMap::new();
3430 let clock = get_atomic_clock_realtime();
3431
3432 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3433
3434 let json = load_test_json("ws_orderbook_snapshot.json");
3435 let msg: BybitWsOrderbookDepthMsg = serde_json::from_str(&json).unwrap();
3436 let ws_msg = BybitWsMessage::Orderbook(msg);
3437
3438 handle_ws_message(
3439 &ws_msg,
3440 &tx.into(),
3441 &instruments,
3442 Some(BybitProductType::Linear),
3443 &trade_subs,
3444 &ticker_subs,
3445 "e_subs,
3446 &book_depths,
3447 &book_sync,
3448 &ws_client,
3449 &greeks_subs,
3450 &Arc::new(AtomicMap::new()),
3451 &bar_types,
3452 true,
3453 &DashMap::new(),
3454 &mut funding_cache,
3455 Duration::ZERO,
3456 &tasks,
3457 clock,
3458 );
3459
3460 rx.try_recv().unwrap_err();
3461 }
3462
3463 #[rstest]
3464 fn test_handle_ticker_linear_does_not_emit_quote() {
3465 let instrument = linear_instrument();
3466 let instrument_id = instrument.id();
3467 let instruments = build_instruments(&[instrument]);
3468 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3469 empty_subs();
3470 let (book_sync, ws_client, tasks) = book_context();
3471
3472 let mut subs = AHashSet::new();
3473 subs.insert("quotes");
3474 ticker_subs.insert(instrument_id, subs);
3475
3476 let mut funding_cache = AHashMap::new();
3477 let clock = get_atomic_clock_realtime();
3478
3479 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3480
3481 let json = load_test_json("ws_ticker_linear.json");
3482 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
3483 let ws_msg = BybitWsMessage::TickerLinear(msg);
3484
3485 handle_ws_message(
3486 &ws_msg,
3487 &tx.into(),
3488 &instruments,
3489 Some(BybitProductType::Linear),
3490 &trade_subs,
3491 &ticker_subs,
3492 "e_subs,
3493 &book_depths,
3494 &book_sync,
3495 &ws_client,
3496 &greeks_subs,
3497 &Arc::new(AtomicMap::new()),
3498 &bar_types,
3499 true,
3500 &DashMap::new(),
3501 &mut funding_cache,
3502 Duration::ZERO,
3503 &tasks,
3504 clock,
3505 );
3506
3507 rx.try_recv().unwrap_err();
3508 }
3509
3510 #[rstest]
3511 fn test_handle_ticker_linear_funding_dedup() {
3512 let instrument = linear_instrument();
3513 let instrument_id = instrument.id();
3514 let instruments = build_instruments(&[instrument]);
3515 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3516 empty_subs();
3517 let (book_sync, ws_client, tasks) = book_context();
3518
3519 let mut subs = AHashSet::new();
3520 subs.insert("funding");
3521 ticker_subs.insert(instrument_id, subs);
3522
3523 let mut funding_cache = AHashMap::new();
3524 let clock = get_atomic_clock_realtime();
3525
3526 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3527
3528 let json = load_test_json("ws_ticker_linear.json");
3529 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
3530 let ws_msg = BybitWsMessage::TickerLinear(msg.clone());
3531
3532 handle_ws_message(
3533 &ws_msg,
3534 &tx.clone().into(),
3535 &instruments,
3536 Some(BybitProductType::Linear),
3537 &trade_subs,
3538 &ticker_subs,
3539 "e_subs,
3540 &book_depths,
3541 &book_sync,
3542 &ws_client,
3543 &greeks_subs,
3544 &Arc::new(AtomicMap::new()),
3545 &bar_types,
3546 true,
3547 &DashMap::new(),
3548 &mut funding_cache,
3549 Duration::ZERO,
3550 &tasks,
3551 clock,
3552 );
3553
3554 let event = rx.try_recv().unwrap();
3555 assert!(matches!(event, DataEvent::FundingRate(_)));
3556
3557 let ws_msg2 = BybitWsMessage::TickerLinear(msg);
3559 handle_ws_message(
3560 &ws_msg2,
3561 &tx.into(),
3562 &instruments,
3563 Some(BybitProductType::Linear),
3564 &trade_subs,
3565 &ticker_subs,
3566 "e_subs,
3567 &book_depths,
3568 &book_sync,
3569 &ws_client,
3570 &greeks_subs,
3571 &Arc::new(AtomicMap::new()),
3572 &bar_types,
3573 true,
3574 &DashMap::new(),
3575 &mut funding_cache,
3576 Duration::ZERO,
3577 &tasks,
3578 clock,
3579 );
3580
3581 rx.try_recv().unwrap_err();
3582 }
3583
3584 #[rstest]
3585 fn test_handle_ticker_linear_mark_and_index_prices() {
3586 let instrument = linear_instrument();
3587 let instrument_id = instrument.id();
3588 let instruments = build_instruments(&[instrument]);
3589 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3590 empty_subs();
3591 let (book_sync, ws_client, tasks) = book_context();
3592
3593 let mut subs = AHashSet::new();
3594 subs.insert("mark_prices");
3595 subs.insert("index_prices");
3596 ticker_subs.insert(instrument_id, subs);
3597
3598 let mut funding_cache = AHashMap::new();
3599 let clock = get_atomic_clock_realtime();
3600
3601 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3602
3603 let json = load_test_json("ws_ticker_linear.json");
3604 let msg: BybitWsTickerLinearMsg = serde_json::from_str(&json).unwrap();
3605 let ws_msg = BybitWsMessage::TickerLinear(msg);
3606
3607 handle_ws_message(
3608 &ws_msg,
3609 &tx.into(),
3610 &instruments,
3611 Some(BybitProductType::Linear),
3612 &trade_subs,
3613 &ticker_subs,
3614 "e_subs,
3615 &book_depths,
3616 &book_sync,
3617 &ws_client,
3618 &greeks_subs,
3619 &Arc::new(AtomicMap::new()),
3620 &bar_types,
3621 true,
3622 &DashMap::new(),
3623 &mut funding_cache,
3624 Duration::ZERO,
3625 &tasks,
3626 clock,
3627 );
3628
3629 let event1 = rx.try_recv().unwrap();
3630 assert!(matches!(event1, DataEvent::Data(Data::MarkPrice(_))));
3631
3632 let event2 = rx.try_recv().unwrap();
3633 assert!(matches!(event2, DataEvent::Data(Data::IndexPrice(_))));
3634 }
3635
3636 #[rstest]
3637 #[case::option(BybitProductType::Option, true)]
3638 #[case::linear(BybitProductType::Linear, false)]
3639 fn test_handle_reconnected_clears_caches(
3640 #[case] product_type: BybitProductType,
3641 #[case] clear_option_quotes: bool,
3642 ) {
3643 let instruments = AHashMap::new();
3644 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3645 empty_subs();
3646 let (book_sync, ws_client, tasks) = book_context();
3647
3648 let mut funding_cache = AHashMap::new();
3649 let clock = get_atomic_clock_realtime();
3650
3651 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3652
3653 let option_quote = QuoteTick::new(
3654 instrument_id,
3655 Price::from("100.00"),
3656 Price::from("101.00"),
3657 Quantity::from("1.0"),
3658 Quantity::from("1.0"),
3659 UnixNanos::default(),
3660 UnixNanos::default(),
3661 );
3662 funding_cache.insert(
3663 Ustr::from("BTCUSDT"),
3664 (
3665 Some("-0.001".to_string()),
3666 Some("1000".to_string()),
3667 Some("8".to_string()),
3668 ),
3669 );
3670
3671 let last_quotes = DashMap::new();
3672 last_quotes.insert(instrument_id, option_quote);
3673
3674 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3675
3676 handle_ws_message(
3677 &BybitWsMessage::Reconnected,
3678 &tx.into(),
3679 &instruments,
3680 Some(product_type),
3681 &trade_subs,
3682 &ticker_subs,
3683 "e_subs,
3684 &book_depths,
3685 &book_sync,
3686 &ws_client,
3687 &greeks_subs,
3688 &Arc::new(AtomicMap::new()),
3689 &bar_types,
3690 true,
3691 &last_quotes,
3692 &mut funding_cache,
3693 Duration::ZERO,
3694 &tasks,
3695 clock,
3696 );
3697
3698 assert!(funding_cache.is_empty());
3699 assert_eq!(
3700 last_quotes.get(&instrument_id).map(|quote| *quote),
3701 if clear_option_quotes {
3702 None
3703 } else {
3704 Some(option_quote)
3705 },
3706 );
3707 }
3708
3709 #[rstest]
3710 fn test_handle_ticker_option_greeks() {
3711 let instrument = option_instrument();
3714 let instrument_id = instrument.id();
3715
3716 let ticker_key = Ustr::from("BTC-6JAN23-17500-C-OPTION");
3718 let mut instruments = AHashMap::new();
3719 instruments.insert(ticker_key, instrument);
3720
3721 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3722 empty_subs();
3723
3724 let (book_sync, ws_client, tasks) = book_context();
3725 greeks_subs.insert(instrument_id);
3726
3727 let mut funding_cache = AHashMap::new();
3728 let clock = get_atomic_clock_realtime();
3729
3730 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3731
3732 let json = load_test_json("ws_ticker_option.json");
3733 let msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
3734 let ws_msg = BybitWsMessage::TickerOption(msg);
3735
3736 handle_ws_message(
3737 &ws_msg,
3738 &tx.into(),
3739 &instruments,
3740 Some(BybitProductType::Option),
3741 &trade_subs,
3742 &ticker_subs,
3743 "e_subs,
3744 &book_depths,
3745 &book_sync,
3746 &ws_client,
3747 &greeks_subs,
3748 &Arc::new(AtomicMap::new()),
3749 &bar_types,
3750 true,
3751 &DashMap::new(),
3752 &mut funding_cache,
3753 Duration::ZERO,
3754 &tasks,
3755 clock,
3756 );
3757
3758 let event = rx.try_recv().unwrap();
3759 assert!(matches!(event, DataEvent::OptionGreeks(_)));
3760 }
3761
3762 #[rstest]
3763 #[case::bid_price("9.5", "10", "2", "5")]
3764 #[case::ask_price("9", "11", "2", "5")]
3765 #[case::bid_size("9", "10", "3", "5")]
3766 #[case::ask_size("9", "10", "2", "6")]
3767 fn test_handle_ticker_option_quote_dedup(
3768 #[case] bid_price: &str,
3769 #[case] ask_price: &str,
3770 #[case] bid_size: &str,
3771 #[case] ask_size: &str,
3772 ) {
3773 let instrument = option_instrument();
3775 let instrument_id = instrument.id();
3776 let mut instruments = AHashMap::new();
3777 instruments.insert(Ustr::from("BTC-6JAN23-17500-C-OPTION"), instrument.clone());
3778
3779 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
3780 empty_subs();
3781 let (book_sync, ws_client, tasks) = book_context();
3782
3783 let mut subs = AHashSet::new();
3784 subs.insert("quotes");
3785 subs.insert("mark_prices");
3786 ticker_subs.insert(instrument_id, subs);
3787
3788 let last_quotes = DashMap::new();
3789 let mut funding_cache = AHashMap::new();
3790 let ts1 = UnixNanos::new(1_672_917_511_100_000_000);
3791 let ts2 = UnixNanos::new(1_672_917_511_200_000_000);
3792 let ts3 = UnixNanos::new(1_672_917_511_300_000_000);
3793 let ts4 = UnixNanos::new(1_672_917_511_400_000_000);
3794 let clock = AtomicTime::new(false, ts1);
3795 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3796
3797 let json = load_test_json("ws_ticker_option.json");
3798 let mut msg: BybitWsTickerOptionMsg = serde_json::from_str(&json).unwrap();
3799
3800 msg.data.bid_price = "9".to_string();
3801 msg.data.bid_size = "2".to_string();
3802 msg.data.ask_size = "5".to_string();
3803 msg.data.mark_price = "7.8".to_string();
3804
3805 let mut unchanged = msg.clone();
3807 unchanged.ts += 100;
3808 let mut changed = msg.clone();
3809 changed.ts += 200;
3810 changed.data.bid_price = bid_price.to_string();
3811 changed.data.ask_price = ask_price.to_string();
3812 changed.data.bid_size = bid_size.to_string();
3813 changed.data.ask_size = ask_size.to_string();
3814 changed.data.mark_price = "8.2".to_string();
3815 let mut repeated = changed.clone();
3816 repeated.ts += 100;
3817
3818 for (ts_init, msg) in [
3819 (ts1, msg),
3820 (ts2, unchanged),
3821 (ts3, changed),
3822 (ts4, repeated),
3823 ] {
3824 clock.set_time(ts_init);
3825 handle_ws_message(
3826 &BybitWsMessage::TickerOption(msg),
3827 &tx.clone().into(),
3828 &instruments,
3829 Some(BybitProductType::Option),
3830 &trade_subs,
3831 &ticker_subs,
3832 "e_subs,
3833 &book_depths,
3834 &book_sync,
3835 &ws_client,
3836 &greeks_subs,
3837 &Arc::new(AtomicMap::new()),
3838 &bar_types,
3839 true,
3840 &last_quotes,
3841 &mut funding_cache,
3842 Duration::ZERO,
3843 &tasks,
3844 &clock,
3845 );
3846 }
3847
3848 let mut quotes = Vec::new();
3849 let mut mark_prices = Vec::new();
3850
3851 while let Ok(event) = rx.try_recv() {
3852 match event {
3853 DataEvent::Data(Data::Quote(quote)) => quotes.push(quote),
3854 DataEvent::Data(Data::MarkPrice(mark)) => mark_prices.push(mark),
3855 other => panic!("Unexpected event: {other:?}"),
3856 }
3857 }
3858
3859 let price = |value: &str| {
3860 Price::from_decimal_dp(value.parse().unwrap(), instrument.price_precision()).unwrap()
3861 };
3862
3863 let size = |value: &str| {
3864 Quantity::from_decimal_dp(value.parse().unwrap(), instrument.size_precision()).unwrap()
3865 };
3866
3867 let initial = QuoteTick::new(
3868 instrument_id,
3869 price("9"),
3870 price("10"),
3871 size("2"),
3872 size("5"),
3873 UnixNanos::new(1_672_917_511_074_000_000),
3874 ts1,
3875 );
3876
3877 let changed = QuoteTick::new(
3878 instrument_id,
3879 price(bid_price),
3880 price(ask_price),
3881 size(bid_size),
3882 size(ask_size),
3883 UnixNanos::new(1_672_917_511_274_000_000),
3884 ts3,
3885 );
3886
3887 let expected_marks = [
3888 ("7.8", 1_672_917_511_074_000_000, ts1),
3889 ("7.8", 1_672_917_511_174_000_000, ts2),
3890 ("8.2", 1_672_917_511_274_000_000, ts3),
3891 ("8.2", 1_672_917_511_374_000_000, ts4),
3892 ]
3893 .map(|(value, ts_event, ts_init)| {
3894 MarkPriceUpdate::new(
3895 instrument_id,
3896 price(value),
3897 UnixNanos::new(ts_event),
3898 ts_init,
3899 )
3900 });
3901
3902 assert_eq!(quotes, vec![initial, changed]);
3903 assert_eq!(mark_prices, expected_marks);
3904 }
3905
3906 #[rstest]
3907 #[case::resubscribe(false, false)]
3908 #[case::resubscribe_after_late_quote(false, true)]
3909 #[case::reconnect(true, false)]
3910 #[tokio::test]
3911 async fn test_option_quote_subscription_emits_unchanged_initial_quote(
3912 #[case] reconnect: bool,
3913 #[case] late_quote: bool,
3914 ) {
3915 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3916 set_data_event_sender(tx);
3917
3918 let mut client = BybitDataClient::new(
3919 *BYBIT_CLIENT_ID,
3920 BybitDataClientConfig {
3921 product_types: vec![BybitProductType::Option],
3922 ..Default::default()
3923 },
3924 )
3925 .unwrap();
3926
3927 let instrument = option_instrument();
3928 let instrument_id = instrument.id();
3929 client.instruments.insert(instrument_id, instrument.clone());
3930 let instruments = build_instruments(std::slice::from_ref(&instrument));
3931 let ts1 = UnixNanos::new(1_700_000_000_000_000_000);
3932 let ts2 = UnixNanos::new(1_700_000_000_100_000_000);
3933 let ts3 = UnixNanos::new(1_700_000_000_200_000_000);
3934 let clock = AtomicTime::new(false, ts1);
3935 let mut msg: BybitWsTickerOptionMsg =
3936 serde_json::from_str(&load_test_json("ws_ticker_option.json")).unwrap();
3937 msg.data.symbol = instrument.raw_symbol().inner();
3938 msg.data.bid_price = "9".to_string();
3939 msg.data.ask_price = "10".to_string();
3940 msg.data.bid_size = "2".to_string();
3941 msg.data.ask_size = "5".to_string();
3942
3943 let initial = QuoteTick::new(
3944 instrument_id,
3945 Price::from("9.0"),
3946 Price::from("10.0"),
3947 Quantity::from("2"),
3948 Quantity::from("5"),
3949 UnixNanos::new(1_672_917_511_074_000_000),
3950 ts1,
3951 );
3952
3953 let subscribe = || {
3954 SubscribeQuotes::new(
3955 instrument_id,
3956 Some(*BYBIT_CLIENT_ID),
3957 None,
3958 UUID4::new(),
3959 UnixNanos::default(),
3960 None,
3961 None,
3962 )
3963 };
3964
3965 let handle = |client: &BybitDataClient, message: &BybitWsMessage| {
3966 let ws = client
3967 .get_ws_client_for_product(BybitProductType::Option)
3968 .unwrap();
3969 handle_ws_message(
3970 message,
3971 &client.data_sender,
3972 &instruments,
3973 Some(BybitProductType::Option),
3974 &client.trade_subs,
3975 &client.ticker_subs,
3976 &client.quote_subs,
3977 &client.book_depths,
3978 &client.book_sync,
3979 ws,
3980 &client.option_greeks_subs,
3981 &client.liquidation_subs,
3982 ws.bar_types_cache(),
3983 true,
3984 &client.last_quotes,
3985 &mut AHashMap::new(),
3986 Duration::ZERO,
3987 &client.command_tasks.spawner().unwrap(),
3988 &clock,
3989 );
3990 };
3991
3992 client.subscribe_quotes(subscribe()).unwrap();
3993 handle(&client, &BybitWsMessage::TickerOption(msg.clone()));
3994 if reconnect {
3995 handle(&client, &BybitWsMessage::Reconnected);
3996 } else {
3997 client
3998 .unsubscribe_quotes(&UnsubscribeQuotes::new(
3999 instrument_id,
4000 Some(*BYBIT_CLIENT_ID),
4001 None,
4002 UUID4::new(),
4003 UnixNanos::default(),
4004 None,
4005 None,
4006 ))
4007 .unwrap();
4008 clock.set_time(ts2);
4009 msg.ts += 100;
4010 handle(&client, &BybitWsMessage::TickerOption(msg.clone()));
4011 if late_quote {
4012 client.last_quotes.insert(instrument_id, initial);
4013 }
4014
4015 client.subscribe_quotes(subscribe()).unwrap();
4016 }
4017
4018 clock.set_time(ts3);
4019 msg.ts = 1_672_917_511_274;
4020 handle(&client, &BybitWsMessage::TickerOption(msg));
4021 let mut quotes = Vec::new();
4022
4023 while let Ok(event) = rx.try_recv() {
4024 match event {
4025 DataEvent::Data(Data::Quote(quote)) => quotes.push(quote),
4026 other => panic!("Unexpected event: {other:?}"),
4027 }
4028 }
4029
4030 assert_eq!(
4031 quotes,
4032 vec![
4033 initial,
4034 QuoteTick {
4035 ts_event: UnixNanos::new(1_672_917_511_274_000_000),
4036 ts_init: ts3,
4037 ..initial
4038 }
4039 ]
4040 );
4041 }
4042
4043 #[rstest]
4044 fn test_handle_execution_message_ignored_by_data() {
4045 let instruments = AHashMap::new();
4046 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
4047 empty_subs();
4048 let (book_sync, ws_client, tasks) = book_context();
4049
4050 let mut funding_cache = AHashMap::new();
4051 let clock = get_atomic_clock_realtime();
4052
4053 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4054
4055 let json = load_test_json("ws_account_order.json");
4056 let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
4057 serde_json::from_str(&json).unwrap();
4058 let ws_msg = BybitWsMessage::AccountOrder(msg);
4059
4060 handle_ws_message(
4061 &ws_msg,
4062 &tx.into(),
4063 &instruments,
4064 None,
4065 &trade_subs,
4066 &ticker_subs,
4067 "e_subs,
4068 &book_depths,
4069 &book_sync,
4070 &ws_client,
4071 &greeks_subs,
4072 &Arc::new(AtomicMap::new()),
4073 &bar_types,
4074 true,
4075 &DashMap::new(),
4076 &mut funding_cache,
4077 Duration::ZERO,
4078 &tasks,
4079 clock,
4080 );
4081
4082 rx.try_recv().unwrap_err();
4083 }
4084
4085 #[rstest]
4086 fn test_instrument_resolution_with_product_type() {
4087 let instrument = linear_instrument();
4088
4089 let mut map = AHashMap::new();
4090 map.insert(instrument.id().symbol.inner(), instrument.clone());
4091
4092 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
4093 empty_subs();
4094
4095 let (book_sync, ws_client, tasks) = book_context();
4096 trade_subs.insert(instrument.id());
4097
4098 let mut funding_cache = AHashMap::new();
4099 let clock = get_atomic_clock_realtime();
4100 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4101
4102 let json = load_test_json("ws_public_trade.json");
4103 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
4104
4105 handle_ws_message(
4107 &BybitWsMessage::Trade(msg.clone()),
4108 &tx.clone().into(),
4109 &map,
4110 None,
4111 &trade_subs,
4112 &ticker_subs,
4113 "e_subs,
4114 &book_depths,
4115 &book_sync,
4116 &ws_client,
4117 &greeks_subs,
4118 &Arc::new(AtomicMap::new()),
4119 &bar_types,
4120 true,
4121 &DashMap::new(),
4122 &mut funding_cache,
4123 Duration::ZERO,
4124 &tasks,
4125 clock,
4126 );
4127 rx.try_recv().unwrap_err();
4128
4129 handle_ws_message(
4131 &BybitWsMessage::Trade(msg),
4132 &tx.into(),
4133 &map,
4134 Some(BybitProductType::Linear),
4135 &trade_subs,
4136 &ticker_subs,
4137 "e_subs,
4138 &book_depths,
4139 &book_sync,
4140 &ws_client,
4141 &greeks_subs,
4142 &Arc::new(AtomicMap::new()),
4143 &bar_types,
4144 true,
4145 &DashMap::new(),
4146 &mut funding_cache,
4147 Duration::ZERO,
4148 &tasks,
4149 clock,
4150 );
4151
4152 let event = rx.try_recv().unwrap();
4153 assert!(matches!(event, DataEvent::Data(Data::Trade(_))));
4154 }
4155
4156 #[rstest]
4157 fn test_handle_trade_filters_by_subscription() {
4158 let instrument = linear_instrument();
4159 let instruments = build_instruments(std::slice::from_ref(&instrument));
4160 let (trade_subs, ticker_subs, quote_subs, book_depths, greeks_subs, bar_types) =
4161 empty_subs();
4162 let (book_sync, ws_client, tasks) = book_context();
4163
4164 let mut funding_cache = AHashMap::new();
4165 let clock = get_atomic_clock_realtime();
4166 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4167
4168 let json = load_test_json("ws_public_trade.json");
4169 let msg: BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
4170
4171 handle_ws_message(
4173 &BybitWsMessage::Trade(msg.clone()),
4174 &tx.clone().into(),
4175 &instruments,
4176 Some(BybitProductType::Linear),
4177 &trade_subs,
4178 &ticker_subs,
4179 "e_subs,
4180 &book_depths,
4181 &book_sync,
4182 &ws_client,
4183 &greeks_subs,
4184 &Arc::new(AtomicMap::new()),
4185 &bar_types,
4186 true,
4187 &DashMap::new(),
4188 &mut funding_cache,
4189 Duration::ZERO,
4190 &tasks,
4191 clock,
4192 );
4193 rx.try_recv().unwrap_err();
4194
4195 trade_subs.insert(instrument.id());
4197 handle_ws_message(
4198 &BybitWsMessage::Trade(msg),
4199 &tx.into(),
4200 &instruments,
4201 Some(BybitProductType::Linear),
4202 &trade_subs,
4203 &ticker_subs,
4204 "e_subs,
4205 &book_depths,
4206 &book_sync,
4207 &ws_client,
4208 &greeks_subs,
4209 &Arc::new(AtomicMap::new()),
4210 &bar_types,
4211 true,
4212 &DashMap::new(),
4213 &mut funding_cache,
4214 Duration::ZERO,
4215 &tasks,
4216 clock,
4217 );
4218 let event = rx.try_recv().unwrap();
4219 assert!(matches!(event, DataEvent::Data(Data::Trade(_))));
4220 }
4221}