1use std::{
19 sync::{
20 Arc,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::Duration,
24};
25
26use ahash::{AHashMap, AHashSet};
27use futures_util::{SinkExt, StreamExt};
28use nautilus_common::{
29 clients::DataClient,
30 live::{runner::get_data_event_sender, sender::EventSender},
31 messages::{
32 DataEvent,
33 data::{
34 subscribe::{SubscribeFundingRates, SubscribeIndexPrices, SubscribeMarkPrices},
35 unsubscribe::{UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeMarkPrices},
36 },
37 },
38};
39use nautilus_core::{consts::NAUTILUS_USER_AGENT, string::urlencoding};
40use nautilus_live::task::TaskGroup;
41use nautilus_model::{
42 data::Data,
43 identifiers::{ClientId, Venue},
44};
45use nautilus_network::url_origin;
46use tokio_tungstenite::{
47 connect_async,
48 tungstenite::{
49 self,
50 client::IntoClientRequest,
51 http::{HeaderValue, header::USER_AGENT},
52 },
53};
54use tokio_util::sync::CancellationToken;
55
56use crate::{
57 common::{
58 consts::{
59 WS_HEARTBEAT_INTERVAL_SECS, WS_INITIAL_RECONNECT_DELAY_SECS,
60 WS_MAX_RECONNECT_DELAY_SECS,
61 },
62 enums::TardisDataType,
63 urls::resolve_ws_base_url,
64 },
65 config::{BookSnapshotOutput, TardisDataClientConfig},
66 http::TardisHttpClient,
67 machine::{
68 cache::DerivativeTickerCache,
69 client::determine_instrument_info,
70 is_unsupported_streaming_error,
71 message::{WsMessage, decode_ws_message},
72 parse::{
73 parse_derivative_ticker_index_price, parse_derivative_ticker_mark_price,
74 parse_tardis_ws_message_data, parse_tardis_ws_message_funding_rate,
75 },
76 types::{TardisInstrumentKey, TardisInstrumentMiniInfo},
77 validate_stream_options,
78 },
79};
80
81#[derive(Debug)]
83pub struct TardisDataClient {
84 client_id: ClientId,
85 config: TardisDataClientConfig,
86 is_connected: Arc<AtomicBool>,
87 cancellation_token: CancellationToken,
88 tasks: TaskGroup,
89 data_sender: EventSender<DataEvent>,
90}
91
92impl TardisDataClient {
93 pub fn new(client_id: ClientId, config: TardisDataClientConfig) -> anyhow::Result<Self> {
104 config.validate()?;
105
106 let data_sender = get_data_event_sender();
107
108 let tasks = TaskGroup::new();
109
110 Ok(Self {
111 client_id,
112 config,
113 is_connected: Arc::new(AtomicBool::new(false)),
114 cancellation_token: tasks.cancellation_token(),
115 tasks,
116 data_sender,
117 })
118 }
119
120 fn is_stream_mode(&self) -> bool {
122 self.config.options.is_empty() && !self.config.stream_options.is_empty()
123 }
124
125 fn build_ws_url(&self, base_url: &str) -> anyhow::Result<String> {
131 let deriv = TardisDataType::DerivativeTicker.as_tardis_str();
132
133 if self.is_stream_mode() {
134 let mut options = self.config.stream_options.clone();
135 for opt in &mut options {
136 if !opt.data_types.iter().any(|dt| dt == deriv) {
137 opt.data_types.push(deriv.to_string());
138 }
139 }
140 let options_json = serde_json::to_string(&options)?;
141 Ok(format!(
142 "{base_url}/ws-stream-normalized?options={}",
143 urlencoding::encode(&options_json)
144 ))
145 } else {
146 let mut options = self.config.options.clone();
147 for opt in &mut options {
148 if !opt.data_types.iter().any(|dt| dt == deriv) {
149 opt.data_types.push(deriv.to_string());
150 }
151 }
152 let options_json = serde_json::to_string(&options)?;
153 Ok(format!(
154 "{base_url}/ws-replay-normalized?options={}",
155 urlencoding::encode(&options_json)
156 ))
157 }
158 }
159
160 fn spawn_ws_task(
165 &self,
166 ws_stream: tokio_tungstenite::WebSocketStream<
167 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
168 >,
169 url: String,
170 instrument_map: AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
171 book_snapshot_output: BookSnapshotOutput,
172 extract_bbo_as_quotes: bool,
173 is_stream_mode: bool,
174 ) -> anyhow::Result<()> {
175 let sender = self.data_sender.clone();
176 let cancel = self.cancellation_token.clone();
177 let connected = self.is_connected.clone();
178
179 let future = async move {
180 let mut reconnect_delay = Duration::from_secs(WS_INITIAL_RECONNECT_DELAY_SECS);
181 let instrument_map = instrument_map;
182
183 let should_reconnect = Self::run_ws_session(
185 ws_stream,
186 &cancel,
187 &sender,
188 &instrument_map,
189 &book_snapshot_output,
190 extract_bbo_as_quotes,
191 )
192 .await;
193
194 if !should_reconnect || !is_stream_mode || cancel.is_cancelled() {
195 connected.store(false, Ordering::Release);
196 return;
197 }
198
199 connected.store(false, Ordering::Release);
201
202 loop {
204 log::warn!(
205 "Stream disconnected, reconnecting in {}s",
206 reconnect_delay.as_secs()
207 );
208
209 tokio::select! {
210 () = tokio::time::sleep(reconnect_delay) => {}
211 () = cancel.cancelled() => break,
212 }
213
214 reconnect_delay = std::cmp::min(
215 reconnect_delay * 2,
216 Duration::from_secs(WS_MAX_RECONNECT_DELAY_SECS),
217 );
218
219 let ws_request = match build_ws_request(&url) {
221 Ok(request) => request,
222 Err(_) => {
223 log::error!(
224 "Failed to build Tardis Machine request: {}",
225 url_origin(&url)
226 );
227 break;
228 }
229 };
230
231 let ws_result = tokio::select! {
232 result = connect_async(ws_request) => Some(result),
233 () = cancel.cancelled() => None,
234 };
235
236 let Some(ws_result) = ws_result else {
237 break;
238 };
239
240 match ws_result {
241 Ok((ws_stream, _)) => {
242 log::info!("Reconnected to Tardis Machine");
243 connected.store(true, Ordering::Release);
244 reconnect_delay = Duration::from_secs(WS_INITIAL_RECONNECT_DELAY_SECS);
245
246 let should_reconnect = Self::run_ws_session(
247 ws_stream,
248 &cancel,
249 &sender,
250 &instrument_map,
251 &book_snapshot_output,
252 extract_bbo_as_quotes,
253 )
254 .await;
255
256 if !should_reconnect || cancel.is_cancelled() {
257 break;
258 }
259
260 connected.store(false, Ordering::Release);
261 }
262 Err(_) => {
263 if cancel.is_cancelled() {
264 break;
265 }
266
267 log::warn!(
268 "Failed to reconnect to Tardis Machine: {}, retrying in {}s",
269 url_origin(&url),
270 reconnect_delay.as_secs()
271 );
272 }
273 }
274 }
275
276 connected.store(false, Ordering::Release);
277 };
278
279 self.tasks
280 .spawn(future)
281 .map_err(|e| anyhow::anyhow!("failed to register Tardis stream task: {e}"))?;
282 Ok(())
283 }
284
285 async fn run_ws_session(
288 ws_stream: tokio_tungstenite::WebSocketStream<
289 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
290 >,
291 cancel: &CancellationToken,
292 sender: &EventSender<DataEvent>,
293 instrument_map: &AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
294 book_snapshot_output: &BookSnapshotOutput,
295 extract_bbo_as_quotes: bool,
296 ) -> bool {
297 let (mut writer, mut reader) = ws_stream.split();
298
299 let heartbeat_token = cancel.child_token();
300 let heartbeat_signal = heartbeat_token.clone();
301
302 let heartbeat = async move {
303 let mut interval =
304 tokio::time::interval(Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS));
305 loop {
306 tokio::select! {
307 _ = interval.tick() => {
308 log::trace!("Sending PING");
309
310 if writer.send(tungstenite::Message::Ping(vec![].into())).await.is_err() {
311 log::debug!("Heartbeat send failed");
312 break;
313 }
314 }
315 () = heartbeat_signal.cancelled() => break,
316 }
317 }
318 };
319
320 let message_loop = async {
321 let should_reconnect = Self::run_ws_loop(
322 &mut reader,
323 cancel,
324 sender,
325 instrument_map,
326 book_snapshot_output,
327 extract_bbo_as_quotes,
328 )
329 .await;
330 heartbeat_token.cancel();
331 should_reconnect
332 };
333 let (should_reconnect, ()) = tokio::join!(message_loop, heartbeat);
334 should_reconnect
335 }
336
337 fn send_derivative_ticker_events(
342 ws_msg: &WsMessage,
343 info: &Arc<TardisInstrumentMiniInfo>,
344 sender: &EventSender<DataEvent>,
345 cache: &mut DerivativeTickerCache,
346 ) -> bool {
347 if let Some(funding) = parse_tardis_ws_message_funding_rate(ws_msg.clone(), info)
348 && cache.should_emit_funding_rate(&funding)
349 && sender
350 .send(DataEvent::Data(Data::FundingRate(funding)))
351 .is_err()
352 {
353 return false;
354 }
355
356 if let WsMessage::DerivativeTicker(msg) = ws_msg {
357 if let Ok(Some(mark_price)) =
358 parse_derivative_ticker_mark_price(msg, info.instrument_id, info.price_precision)
359 && cache.should_emit_mark_price(&mark_price)
360 && sender
361 .send(DataEvent::Data(Data::MarkPrice(mark_price)))
362 .is_err()
363 {
364 return false;
365 }
366
367 if let Ok(Some(index_price)) =
368 parse_derivative_ticker_index_price(msg, info.instrument_id, info.price_precision)
369 && cache.should_emit_index_price(&index_price)
370 && sender
371 .send(DataEvent::Data(Data::IndexPrice(index_price)))
372 .is_err()
373 {
374 return false;
375 }
376 }
377
378 true
379 }
380
381 async fn run_ws_loop(
385 reader: &mut futures_util::stream::SplitStream<
386 tokio_tungstenite::WebSocketStream<
387 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
388 >,
389 >,
390 cancel: &CancellationToken,
391 sender: &EventSender<DataEvent>,
392 instrument_map: &AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
393 book_snapshot_output: &BookSnapshotOutput,
394 extract_bbo_as_quotes: bool,
395 ) -> bool {
396 let mut ticker_cache = DerivativeTickerCache::default();
397
398 loop {
399 let msg = tokio::select! {
400 msg = reader.next() => msg,
401 () = cancel.cancelled() => {
402 log::debug!("Stream task cancelled");
403 return false;
404 }
405 };
406
407 match msg {
408 Some(Ok(tungstenite::Message::Text(text))) => match decode_ws_message(&text) {
409 Ok(ws_msg) => {
410 if matches!(ws_msg, WsMessage::Disconnect(_)) {
411 log::debug!("Received disconnect message");
412 continue;
413 }
414
415 let info = determine_instrument_info(&ws_msg, instrument_map);
416
417 if let Some(info) = info {
418 if matches!(ws_msg, WsMessage::DerivativeTicker(_)) {
419 if !Self::send_derivative_ticker_events(
420 &ws_msg,
421 &info,
422 sender,
423 &mut ticker_cache,
424 ) {
425 return false;
426 }
427 } else {
428 let data = parse_tardis_ws_message_data(
429 ws_msg,
430 &info,
431 book_snapshot_output,
432 extract_bbo_as_quotes,
433 );
434
435 for data in data {
436 if let Err(e) = sender.send(DataEvent::Data(data)) {
437 log::error!("Failed to send data event: {e}");
438 return false;
439 }
440 }
441 }
442 }
443 }
444 Err(e) => {
445 log::error!(
446 "Failed to deserialize message at line {}, column {}",
447 e.line(),
448 e.column()
449 );
450 }
451 },
452 Some(Ok(tungstenite::Message::Close(Some(frame))))
453 if is_unsupported_streaming_error(&frame.reason) =>
454 {
455 log::error!("Tardis Machine rejected streaming");
456 return false;
457 }
458 Some(Ok(tungstenite::Message::Close(frame))) => {
459 if let Some(frame) = frame {
460 log::debug!("WebSocket closed: {}", frame.code);
461 } else {
462 log::debug!("WebSocket closed");
463 }
464 return true;
465 }
466 Some(Ok(_)) => {}
467 Some(Err(_)) => {
468 log::warn!("WebSocket error");
469 return true;
470 }
471 None => {
472 log::debug!("Stream ended");
473 return true;
474 }
475 }
476 }
477 }
478}
479
480#[async_trait::async_trait(?Send)]
481impl DataClient for TardisDataClient {
482 fn client_id(&self) -> ClientId {
483 self.client_id
484 }
485
486 fn venue(&self) -> Option<Venue> {
487 None }
489
490 fn start(&mut self) -> anyhow::Result<()> {
491 log::info!("Starting {}", self.client_id);
492 Ok(())
493 }
494
495 fn stop(&mut self) -> anyhow::Result<()> {
496 log::info!("Stopping {}", self.client_id);
497 self.tasks.begin_shutdown();
498 self.is_connected.store(false, Ordering::Release);
499 Ok(())
500 }
501
502 fn reset(&mut self) -> anyhow::Result<()> {
503 self.tasks.begin_shutdown();
504 self.is_connected.store(false, Ordering::Release);
505 Ok(())
506 }
507
508 fn dispose(&mut self) -> anyhow::Result<()> {
509 self.stop()
510 }
511
512 fn is_connected(&self) -> bool {
513 self.is_connected.load(Ordering::Acquire)
514 }
515
516 fn is_disconnected(&self) -> bool {
517 !self.is_connected()
518 }
519
520 fn subscribe_mark_prices(&mut self, _cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
521 Ok(())
522 }
523
524 fn subscribe_index_prices(&mut self, _cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
525 Ok(())
526 }
527
528 fn subscribe_funding_rates(&mut self, _cmd: SubscribeFundingRates) -> anyhow::Result<()> {
529 Ok(())
530 }
531
532 fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
533 Ok(())
534 }
535
536 fn unsubscribe_index_prices(&mut self, _cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
537 Ok(())
538 }
539
540 fn unsubscribe_funding_rates(&mut self, _cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
541 Ok(())
542 }
543
544 async fn connect(&mut self) -> anyhow::Result<()> {
545 if self.is_connected() && self.tasks.is_open() {
546 return Ok(());
547 }
548
549 match (
550 self.config.options.as_slice(),
551 self.config.stream_options.as_slice(),
552 ) {
553 ([], []) => {
554 anyhow::bail!("Either replay `options` or `stream_options` must be provided")
555 }
556 ([], stream_options) => validate_stream_options(stream_options)?,
557 _ => {}
558 }
559
560 if !self.tasks.is_open() {
561 self.tasks
562 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
563 .await
564 .map_err(|e| anyhow::anyhow!("Failed to terminate Tardis tasks: {e}"))?;
565 self.tasks
566 .start_generation()
567 .map_err(|e| anyhow::anyhow!("Failed to start Tardis task generation: {e}"))?;
568 self.cancellation_token = self.tasks.cancellation_token();
569 }
570
571 let is_stream_mode = self.is_stream_mode();
572 let book_snapshot_output = self.config.book_snapshot_output.clone();
573 let extract_bbo_as_quotes = self.config.extract_bbo_as_quotes;
574
575 let http_client = TardisHttpClient::new(
576 self.config
577 .api_key
578 .as_ref()
579 .map(|value| value.expose_secret()),
580 self.config
581 .tardis_http_url
582 .as_ref()
583 .map(|value| value.expose_secret()),
584 None,
585 self.config.normalize_symbols,
586 self.config
587 .proxy_url
588 .as_ref()
589 .map(|value| value.expose_secret().to_owned()),
590 )?;
591
592 let exchanges: AHashSet<_> = if is_stream_mode {
593 self.config
594 .stream_options
595 .iter()
596 .map(|opt| opt.exchange)
597 .collect()
598 } else {
599 self.config.options.iter().map(|opt| opt.exchange).collect()
600 };
601
602 let base_url = resolve_ws_base_url(
603 self.config
604 .tardis_ws_url
605 .as_ref()
606 .map(|value| value.expose_secret()),
607 )?;
608 let (instrument_map, instruments) = http_client
609 .bootstrap_instruments(&exchanges)
610 .await
611 .map_err(|e| anyhow::anyhow!("Failed to bootstrap instruments: {e}"))?;
612
613 for instrument in instruments {
614 if let Err(e) = self.data_sender.send(DataEvent::Data(instrument.into())) {
615 log::error!("Failed to send instrument event: {e}");
616 }
617 }
618
619 let url = self.build_ws_url(&base_url)?;
620
621 let mode_label = if is_stream_mode { "stream" } else { "replay" };
622 log::info!("Connecting to Tardis Machine {mode_label}");
623 log::debug!("URL: {}", url_origin(&url));
624
625 let (ws_stream, _) = connect_async(build_ws_request(&url)?)
626 .await
627 .map_err(|e| anyhow::anyhow!("Failed to connect to Tardis Machine: {e}"))?;
628
629 log::info!("Connected to Tardis Machine");
630
631 self.is_connected.store(true, Ordering::Release);
632
633 if let Err(e) = self.spawn_ws_task(
634 ws_stream,
635 url,
636 instrument_map,
637 book_snapshot_output,
638 extract_bbo_as_quotes,
639 is_stream_mode,
640 ) {
641 self.is_connected.store(false, Ordering::Release);
642 self.tasks.begin_shutdown();
643 if let Err(teardown_error) = self
644 .tasks
645 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
646 .await
647 {
648 return Err(e.context(format!("Tardis startup teardown failed: {teardown_error}")));
649 }
650 return Err(e);
651 }
652
653 log::info!("Connected: {}", self.client_id);
654 Ok(())
655 }
656
657 async fn disconnect(&mut self) -> anyhow::Result<()> {
658 self.tasks.begin_shutdown();
659 let had_tasks = !self.tasks.is_empty();
660 let tasks_result = self
661 .tasks
662 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
663 .await
664 .map_err(|e| anyhow::anyhow!("Failed to terminate Tardis tasks: {e}"));
665
666 if had_tasks {
667 log::info!("Disconnected: {}", self.client_id);
668 }
669
670 self.is_connected.store(false, Ordering::Release);
671
672 tasks_result
673 }
674}
675
676fn build_ws_request(url: &str) -> anyhow::Result<tungstenite::http::Request<()>> {
679 let mut request = url
680 .into_client_request()
681 .map_err(|e| anyhow::anyhow!("Failed to build Tardis Machine WebSocket request: {e}"))?;
682 request
683 .headers_mut()
684 .insert(USER_AGENT, HeaderValue::from_static(NAUTILUS_USER_AGENT));
685 Ok(request)
686}
687
688#[cfg(test)]
689mod tests {
690 use jiff::civil::Date;
691 use nautilus_common::live::runner::set_data_event_sender;
692 use nautilus_core::{UUID4, UnixNanos};
693 use nautilus_model::identifiers::InstrumentId;
694 use rstest::rstest;
695
696 use super::*;
697 use crate::{
698 common::{consts::TARDIS_CLIENT_ID, enums::TardisExchange},
699 config::TardisDataClientConfig,
700 machine::types::{ReplayNormalizedRequestOptions, StreamNormalizedRequestOptions},
701 };
702
703 #[rstest]
704 #[case("mark")]
705 #[case("index")]
706 #[case("funding")]
707 fn test_reference_price_list_hooks_remain_noops(#[case] family: &str) {
708 setup_test_env();
709 let mut client =
710 TardisDataClient::new(*TARDIS_CLIENT_ID, TardisDataClientConfig::default()).unwrap();
711 let ids = [
712 InstrumentId::from("BTCUSDT.BINANCE"),
713 InstrumentId::from("ETHUSDT.BINANCE"),
714 ];
715 let command_id = UUID4::new();
716 let ts_init = UnixNanos::from(17);
717
718 let subscribe = match family {
719 "mark" => client.subscribe_mark_prices(SubscribeMarkPrices::new(
720 ids,
721 Some(*TARDIS_CLIENT_ID),
722 None,
723 command_id,
724 ts_init,
725 None,
726 None,
727 )),
728 "index" => client.subscribe_index_prices(SubscribeIndexPrices::new(
729 ids,
730 Some(*TARDIS_CLIENT_ID),
731 None,
732 command_id,
733 ts_init,
734 None,
735 None,
736 )),
737 "funding" => client.subscribe_funding_rates(SubscribeFundingRates::new(
738 ids,
739 Some(*TARDIS_CLIENT_ID),
740 None,
741 command_id,
742 ts_init,
743 None,
744 None,
745 )),
746 _ => unreachable!(),
747 };
748
749 let unsubscribe = match family {
750 "mark" => client.unsubscribe_mark_prices(&UnsubscribeMarkPrices::new(
751 ids,
752 Some(*TARDIS_CLIENT_ID),
753 None,
754 command_id,
755 ts_init,
756 None,
757 None,
758 )),
759 "index" => client.unsubscribe_index_prices(&UnsubscribeIndexPrices::new(
760 ids,
761 Some(*TARDIS_CLIENT_ID),
762 None,
763 command_id,
764 ts_init,
765 None,
766 None,
767 )),
768 "funding" => client.unsubscribe_funding_rates(&UnsubscribeFundingRates::new(
769 ids,
770 Some(*TARDIS_CLIENT_ID),
771 None,
772 command_id,
773 ts_init,
774 None,
775 None,
776 )),
777 _ => unreachable!(),
778 };
779
780 assert!(subscribe.is_ok());
781 assert!(unsubscribe.is_ok());
782 assert!(!client.is_connected());
783 }
784
785 #[rstest]
786 #[case(vec![], "Either replay `options` or `stream_options` must be provided")]
787 #[case(
788 vec![TardisExchange::Bitmex],
789 "Unsupported Tardis streaming exchange: bitmex (historical-only; use replay options)"
790 )]
791 #[case(
792 vec![TardisExchange::Deribit, TardisExchange::Bitmex],
793 "Unsupported Tardis streaming exchange: bitmex (historical-only; use replay options)"
794 )]
795 #[tokio::test]
796 async fn test_connect_rejects_invalid_stream_options_before_network_access(
797 #[case] exchanges: Vec<TardisExchange>,
798 #[case] expected_error: &str,
799 ) {
800 setup_test_env();
801
802 let config = TardisDataClientConfig {
803 api_key: Some("test-key".into()),
804 tardis_http_url: Some("http://127.0.0.1:0".into()),
805 tardis_ws_url: Some("ws://127.0.0.1:0".into()),
806 stream_options: exchanges
807 .into_iter()
808 .map(|exchange| StreamNormalizedRequestOptions {
809 exchange,
810 symbols: None,
811 data_types: vec!["trade".to_string()],
812 with_disconnect_messages: None,
813 timeout_interval_ms: None,
814 })
815 .collect(),
816 ..Default::default()
817 };
818
819 let mut client = TardisDataClient::new(*TARDIS_CLIENT_ID, config).unwrap();
820
821 let error = client.connect().await.unwrap_err();
822
823 assert_eq!(error.to_string(), expected_error);
824 assert!(client.is_disconnected());
825 }
826
827 #[rstest]
828 #[case(
829 "Error: Real-time streaming is not supported for exchange bitmex",
830 false
831 )]
832 #[case(
833 "Error: Real-time streaming is not supported for exchange coinflex",
834 false
835 )]
836 #[case("Too many subsequent errors when connecting to deribit WS API", true)]
837 #[tokio::test]
838 async fn test_ws_session_close_reconnect_policy(
839 #[case] reason: &'static str,
840 #[case] expected_reconnect: bool,
841 ) {
842 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
843 let address = listener.local_addr().unwrap();
844
845 let server = tokio::spawn(async move {
846 let (socket, _) = listener.accept().await.unwrap();
847 let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap();
848 websocket
849 .close(Some(tungstenite::protocol::CloseFrame {
850 code: tungstenite::protocol::frame::coding::CloseCode::Error,
851 reason: reason.into(),
852 }))
853 .await
854 .unwrap();
855 });
856
857 let (websocket, _) = connect_async(format!("ws://{address}")).await.unwrap();
858 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
859 let reconnect = tokio::time::timeout(
860 Duration::from_secs(2),
861 TardisDataClient::run_ws_session(
862 websocket,
863 &CancellationToken::new(),
864 &EventSender::from(sender),
865 &AHashMap::new(),
866 &BookSnapshotOutput::Deltas,
867 false,
868 ),
869 )
870 .await
871 .unwrap();
872 server.await.unwrap();
873
874 assert_eq!(reconnect, expected_reconnect);
875 }
876
877 #[tokio::test]
878 async fn test_unsupported_exchange_finishes_stream_task_without_reconnecting() {
879 setup_test_env();
880
881 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
882 let url = format!("ws://{}", listener.local_addr().unwrap());
883
884 let server = tokio::spawn(async move {
885 let (socket, _) = listener.accept().await.unwrap();
886 let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap();
887 websocket
888 .close(Some(tungstenite::protocol::CloseFrame {
889 code: tungstenite::protocol::frame::coding::CloseCode::Error,
890 reason: "Error: Real-time streaming is not supported for exchange bitmex"
891 .into(),
892 }))
893 .await
894 .unwrap();
895
896 listener
897 });
898
899 let (websocket, _) = connect_async(&url).await.unwrap();
900 let client =
901 TardisDataClient::new(*TARDIS_CLIENT_ID, TardisDataClientConfig::default()).unwrap();
902 client.is_connected.store(true, Ordering::Release);
903 client
904 .spawn_ws_task(
905 websocket,
906 url,
907 AHashMap::new(),
908 BookSnapshotOutput::Deltas,
909 false,
910 true,
911 )
912 .unwrap();
913
914 tokio::time::timeout(Duration::from_secs(2), async {
915 while !client.tasks.all_finished() {
916 tokio::task::yield_now().await;
917 }
918 })
919 .await
920 .expect("Unsupported exchange must finish the task without reconnecting");
921
922 let listener = server.await.unwrap();
923
924 assert!(client.is_disconnected());
925 assert!(
926 tokio::time::timeout(Duration::from_millis(50), listener.accept())
927 .await
928 .is_err()
929 );
930 }
931
932 #[rstest]
933 fn test_build_ws_request_rejects_invalid_url() {
934 assert!(build_ws_request("not a url").is_err());
935 }
936
937 fn setup_test_env() {
938 use std::cell::OnceCell;
939
940 thread_local! {
941 static INIT: OnceCell<()> = const { OnceCell::new() };
942 }
943
944 INIT.with(|cell| {
945 cell.get_or_init(|| {
946 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
947 set_data_event_sender(sender);
948 });
949 });
950 }
951
952 #[rstest]
953 fn test_build_ws_url_injects_derivative_ticker() {
954 setup_test_env();
955
956 let config = TardisDataClientConfig {
957 options: vec![ReplayNormalizedRequestOptions {
958 exchange: TardisExchange::BinanceFutures,
959 symbols: Some(vec!["BTCUSDT".to_string()]),
960 from: Date::new(2024, 1, 1).unwrap(),
961 to: Date::new(2024, 1, 2).unwrap(),
962 data_types: vec!["trade".to_string()],
963 with_disconnect_messages: Some(false),
964 }],
965 ..Default::default()
966 };
967
968 let client = TardisDataClient::new(*TARDIS_CLIENT_ID, config).unwrap();
969 let url = client.build_ws_url("ws://localhost:8001").unwrap();
970
971 assert!(
972 url.contains("derivative_ticker"),
973 "URL should contain derivative_ticker but was: {url}"
974 );
975 assert!(url.contains("trade"), "URL should still contain trade");
976 }
977
978 #[rstest]
979 fn test_build_ws_url_does_not_duplicate_derivative_ticker() {
980 setup_test_env();
981
982 let config = TardisDataClientConfig {
983 options: vec![ReplayNormalizedRequestOptions {
984 exchange: TardisExchange::BinanceFutures,
985 symbols: Some(vec!["BTCUSDT".to_string()]),
986 from: Date::new(2024, 1, 1).unwrap(),
987 to: Date::new(2024, 1, 2).unwrap(),
988 data_types: vec!["trade".to_string(), "derivative_ticker".to_string()],
989 with_disconnect_messages: Some(false),
990 }],
991 ..Default::default()
992 };
993
994 let client = TardisDataClient::new(*TARDIS_CLIENT_ID, config).unwrap();
995 let ws_url = client.build_ws_url("ws://localhost:8001").unwrap();
996
997 let decoded = urlencoding::decode(ws_url.split("options=").nth(1).unwrap()).unwrap();
998 let count = decoded.matches("derivative_ticker").count();
999 assert_eq!(count, 1, "derivative_ticker should appear exactly once");
1000 }
1001
1002 #[rstest]
1003 fn test_stop_marks_client_disconnected_synchronously() {
1004 setup_test_env();
1005
1006 let mut client =
1007 TardisDataClient::new(*TARDIS_CLIENT_ID, TardisDataClientConfig::default()).unwrap();
1008 client.is_connected.store(true, Ordering::Release);
1009
1010 client.stop().unwrap();
1011
1012 assert!(client.is_disconnected());
1013 }
1014
1015 #[rstest]
1016 fn test_reset_marks_client_disconnected_synchronously() {
1017 setup_test_env();
1018
1019 let mut client =
1020 TardisDataClient::new(*TARDIS_CLIENT_ID, TardisDataClientConfig::default()).unwrap();
1021 client.is_connected.store(true, Ordering::Release);
1022
1023 client.reset().unwrap();
1024
1025 assert!(client.is_disconnected());
1026 }
1027}