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