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, runtime::get_runtime},
31 messages::{
32 DataEvent,
33 data::{
34 subscribe::{SubscribeFundingRates, SubscribeIndexPrices, SubscribeMarkPrices},
35 unsubscribe::{UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeMarkPrices},
36 },
37 },
38};
39use nautilus_core::string::urlencoding;
40use nautilus_model::{
41 data::Data,
42 identifiers::{ClientId, Venue},
43};
44use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle};
45use tokio_tungstenite::{connect_async, tungstenite};
46use tokio_util::sync::CancellationToken;
47
48use crate::{
49 common::{
50 consts::{
51 WS_HEARTBEAT_INTERVAL_SECS, WS_INITIAL_RECONNECT_DELAY_SECS,
52 WS_MAX_RECONNECT_DELAY_SECS,
53 },
54 enums::TardisDataType,
55 urls::resolve_ws_base_url,
56 },
57 config::{BookSnapshotOutput, TardisDataClientConfig},
58 http::TardisHttpClient,
59 machine::{
60 cache::DerivativeTickerCache,
61 client::determine_instrument_info,
62 message::WsMessage,
63 parse::{
64 parse_derivative_ticker_index_price, parse_derivative_ticker_mark_price,
65 parse_tardis_ws_message_data, parse_tardis_ws_message_funding_rate,
66 },
67 types::{TardisInstrumentKey, TardisInstrumentMiniInfo},
68 },
69};
70
71#[derive(Debug)]
73pub struct TardisDataClient {
74 client_id: ClientId,
75 config: TardisDataClientConfig,
76 is_connected: Arc<AtomicBool>,
77 cancellation_token: CancellationToken,
78 tasks: Vec<JoinHandle<()>>,
79 data_sender: UnboundedSender<DataEvent>,
80}
81
82impl TardisDataClient {
83 pub fn new(client_id: ClientId, config: TardisDataClientConfig) -> anyhow::Result<Self> {
89 let data_sender = get_data_event_sender();
90
91 Ok(Self {
92 client_id,
93 config,
94 is_connected: Arc::new(AtomicBool::new(false)),
95 cancellation_token: CancellationToken::new(),
96 tasks: Vec::new(),
97 data_sender,
98 })
99 }
100
101 fn is_stream_mode(&self) -> bool {
103 self.config.options.is_empty() && !self.config.stream_options.is_empty()
104 }
105
106 fn build_ws_url(&self, base_url: &str) -> anyhow::Result<String> {
112 let deriv = TardisDataType::DerivativeTicker.as_tardis_str();
113
114 if self.is_stream_mode() {
115 let mut options = self.config.stream_options.clone();
116 for opt in &mut options {
117 if !opt.data_types.iter().any(|dt| dt == deriv) {
118 opt.data_types.push(deriv.to_string());
119 }
120 }
121 let options_json = serde_json::to_string(&options)?;
122 Ok(format!(
123 "{base_url}/ws-stream-normalized?options={}",
124 urlencoding::encode(&options_json)
125 ))
126 } else {
127 let mut options = self.config.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-replay-normalized?options={}",
136 urlencoding::encode(&options_json)
137 ))
138 }
139 }
140
141 fn spawn_ws_task(
146 &mut self,
147 ws_stream: tokio_tungstenite::WebSocketStream<
148 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
149 >,
150 url: String,
151 instrument_map: AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
152 book_snapshot_output: BookSnapshotOutput,
153 extract_bbo_as_quotes: bool,
154 is_stream_mode: bool,
155 ) {
156 let sender = self.data_sender.clone();
157 let cancel = self.cancellation_token.clone();
158 let connected = self.is_connected.clone();
159
160 let handle = get_runtime().spawn(async move {
161 let mut reconnect_delay = Duration::from_secs(WS_INITIAL_RECONNECT_DELAY_SECS);
162 let instrument_map = instrument_map;
163
164 let should_reconnect = Self::run_ws_session(
166 ws_stream,
167 &cancel,
168 &sender,
169 &instrument_map,
170 &book_snapshot_output,
171 extract_bbo_as_quotes,
172 )
173 .await;
174
175 if !should_reconnect || !is_stream_mode || cancel.is_cancelled() {
176 connected.store(false, Ordering::Release);
177 return;
178 }
179
180 connected.store(false, Ordering::Release);
182
183 loop {
185 log::warn!(
186 "Stream disconnected, reconnecting in {}s",
187 reconnect_delay.as_secs()
188 );
189
190 tokio::select! {
191 () = tokio::time::sleep(reconnect_delay) => {}
192 () = cancel.cancelled() => break,
193 }
194
195 reconnect_delay = std::cmp::min(
196 reconnect_delay * 2,
197 Duration::from_secs(WS_MAX_RECONNECT_DELAY_SECS),
198 );
199
200 let ws_result = tokio::select! {
202 result = connect_async(&url) => Some(result),
203 () = cancel.cancelled() => None,
204 };
205
206 let Some(ws_result) = ws_result else {
207 break;
208 };
209
210 match ws_result {
211 Ok((ws_stream, _)) => {
212 log::info!("Reconnected to Tardis Machine");
213 connected.store(true, Ordering::Release);
214 reconnect_delay = Duration::from_secs(WS_INITIAL_RECONNECT_DELAY_SECS);
215
216 let should_reconnect = Self::run_ws_session(
217 ws_stream,
218 &cancel,
219 &sender,
220 &instrument_map,
221 &book_snapshot_output,
222 extract_bbo_as_quotes,
223 )
224 .await;
225
226 if !should_reconnect || cancel.is_cancelled() {
227 break;
228 }
229
230 connected.store(false, Ordering::Release);
231 }
232 Err(e) => {
233 if cancel.is_cancelled() {
234 break;
235 }
236
237 log::warn!(
238 "Failed to reconnect to Tardis Machine: {e}, retrying in {}s",
239 reconnect_delay.as_secs()
240 );
241 }
242 }
243 }
244
245 connected.store(false, Ordering::Release);
246 });
247
248 self.tasks.push(handle);
249 }
250
251 async fn run_ws_session(
254 ws_stream: tokio_tungstenite::WebSocketStream<
255 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
256 >,
257 cancel: &CancellationToken,
258 sender: &UnboundedSender<DataEvent>,
259 instrument_map: &AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
260 book_snapshot_output: &BookSnapshotOutput,
261 extract_bbo_as_quotes: bool,
262 ) -> bool {
263 let (mut writer, mut reader) = ws_stream.split();
264
265 let heartbeat_token = cancel.child_token();
266 let heartbeat_signal = heartbeat_token.clone();
267
268 get_runtime().spawn(async move {
269 let mut interval =
270 tokio::time::interval(Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS));
271 loop {
272 tokio::select! {
273 _ = interval.tick() => {
274 log::trace!("Sending PING");
275
276 if let Err(e) = writer.send(tungstenite::Message::Ping(vec![].into())).await {
277 log::debug!("Heartbeat send failed: {e}");
278 break;
279 }
280 }
281 () = heartbeat_signal.cancelled() => break,
282 }
283 }
284 });
285
286 let should_reconnect = Self::run_ws_loop(
287 &mut reader,
288 cancel,
289 sender,
290 instrument_map,
291 book_snapshot_output,
292 extract_bbo_as_quotes,
293 )
294 .await;
295
296 heartbeat_token.cancel();
297 should_reconnect
298 }
299
300 fn send_derivative_ticker_events(
305 ws_msg: &WsMessage,
306 info: &Arc<TardisInstrumentMiniInfo>,
307 sender: &UnboundedSender<DataEvent>,
308 cache: &mut DerivativeTickerCache,
309 ) -> bool {
310 if let Some(funding) = parse_tardis_ws_message_funding_rate(ws_msg.clone(), info)
311 && cache.should_emit_funding_rate(&funding)
312 && sender.send(DataEvent::FundingRate(funding)).is_err()
313 {
314 return false;
315 }
316
317 if let WsMessage::DerivativeTicker(msg) = ws_msg {
318 if let Ok(Some(mark_price)) =
319 parse_derivative_ticker_mark_price(msg, info.instrument_id, info.price_precision)
320 && cache.should_emit_mark_price(&mark_price)
321 && sender
322 .send(DataEvent::Data(Data::MarkPriceUpdate(mark_price)))
323 .is_err()
324 {
325 return false;
326 }
327
328 if let Ok(Some(index_price)) =
329 parse_derivative_ticker_index_price(msg, info.instrument_id, info.price_precision)
330 && cache.should_emit_index_price(&index_price)
331 && sender
332 .send(DataEvent::Data(Data::IndexPriceUpdate(index_price)))
333 .is_err()
334 {
335 return false;
336 }
337 }
338
339 true
340 }
341
342 async fn run_ws_loop(
346 reader: &mut futures_util::stream::SplitStream<
347 tokio_tungstenite::WebSocketStream<
348 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
349 >,
350 >,
351 cancel: &CancellationToken,
352 sender: &UnboundedSender<DataEvent>,
353 instrument_map: &AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
354 book_snapshot_output: &BookSnapshotOutput,
355 extract_bbo_as_quotes: bool,
356 ) -> bool {
357 let mut ticker_cache = DerivativeTickerCache::default();
358
359 loop {
360 let msg = tokio::select! {
361 msg = reader.next() => msg,
362 () = cancel.cancelled() => {
363 log::debug!("Stream task cancelled");
364 return false;
365 }
366 };
367
368 match msg {
369 Some(Ok(tungstenite::Message::Text(text))) => {
370 match serde_json::from_str::<WsMessage>(&text) {
371 Ok(ws_msg) => {
372 if matches!(ws_msg, WsMessage::Disconnect(_)) {
373 log::debug!("Received disconnect message");
374 continue;
375 }
376
377 let info = determine_instrument_info(&ws_msg, instrument_map);
378
379 if let Some(info) = info {
380 if matches!(ws_msg, WsMessage::DerivativeTicker(_)) {
381 if !Self::send_derivative_ticker_events(
382 &ws_msg,
383 &info,
384 sender,
385 &mut ticker_cache,
386 ) {
387 return false;
388 }
389 } else {
390 let data = parse_tardis_ws_message_data(
391 ws_msg,
392 &info,
393 book_snapshot_output,
394 extract_bbo_as_quotes,
395 );
396
397 for data in data {
398 if let Err(e) = sender.send(DataEvent::Data(data)) {
399 log::error!("Failed to send data event: {e}");
400 return false;
401 }
402 }
403 }
404 }
405 }
406 Err(e) => {
407 log::error!("Failed to deserialize message: {e}");
408 }
409 }
410 }
411 Some(Ok(tungstenite::Message::Close(frame))) => {
412 if let Some(frame) = frame {
413 log::debug!("WebSocket closed: {} {}", frame.code, frame.reason);
414 } else {
415 log::debug!("WebSocket closed");
416 }
417 return true;
418 }
419 Some(Ok(_)) => {}
420 Some(Err(e)) => {
421 log::warn!("WebSocket error: {e}");
422 return true;
423 }
424 None => {
425 log::debug!("Stream ended");
426 return true;
427 }
428 }
429 }
430 }
431}
432
433#[async_trait::async_trait(?Send)]
434impl DataClient for TardisDataClient {
435 fn client_id(&self) -> ClientId {
436 self.client_id
437 }
438
439 fn venue(&self) -> Option<Venue> {
440 None }
442
443 fn start(&mut self) -> anyhow::Result<()> {
444 log::info!("Starting {}", self.client_id);
445 Ok(())
446 }
447
448 fn stop(&mut self) -> anyhow::Result<()> {
449 log::info!("Stopping {}", self.client_id);
450 self.cancellation_token.cancel();
451
452 for handle in self.tasks.drain(..) {
453 handle.abort();
454 }
455 self.is_connected.store(false, Ordering::Release);
456 Ok(())
457 }
458
459 fn reset(&mut self) -> anyhow::Result<()> {
460 self.cancellation_token.cancel();
461
462 for handle in self.tasks.drain(..) {
463 handle.abort();
464 }
465 self.cancellation_token = CancellationToken::new();
466 self.is_connected.store(false, Ordering::Release);
467 Ok(())
468 }
469
470 fn dispose(&mut self) -> anyhow::Result<()> {
471 self.stop()
472 }
473
474 fn is_connected(&self) -> bool {
475 self.is_connected.load(Ordering::Acquire)
476 }
477
478 fn is_disconnected(&self) -> bool {
479 !self.is_connected()
480 }
481
482 fn subscribe_mark_prices(&mut self, _cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
483 Ok(())
484 }
485
486 fn subscribe_index_prices(&mut self, _cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
487 Ok(())
488 }
489
490 fn subscribe_funding_rates(&mut self, _cmd: SubscribeFundingRates) -> anyhow::Result<()> {
491 Ok(())
492 }
493
494 fn unsubscribe_mark_prices(&mut self, _cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
495 Ok(())
496 }
497
498 fn unsubscribe_index_prices(&mut self, _cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
499 Ok(())
500 }
501
502 fn unsubscribe_funding_rates(&mut self, _cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
503 Ok(())
504 }
505
506 async fn connect(&mut self) -> anyhow::Result<()> {
507 if self.is_connected() {
508 return Ok(());
509 }
510
511 if self.config.options.is_empty() && self.config.stream_options.is_empty() {
512 anyhow::bail!("Either replay `options` or `stream_options` must be provided");
513 }
514
515 let is_stream_mode = self.is_stream_mode();
516 let book_snapshot_output = self.config.book_snapshot_output.clone();
517 let extract_bbo_as_quotes = self.config.extract_bbo_as_quotes;
518
519 let http_client = TardisHttpClient::new(
520 self.config.api_key.as_deref(),
521 None,
522 None,
523 self.config.normalize_symbols,
524 self.config.proxy_url.clone(),
525 )?;
526
527 let exchanges: AHashSet<_> = if is_stream_mode {
528 self.config
529 .stream_options
530 .iter()
531 .map(|opt| opt.exchange)
532 .collect()
533 } else {
534 self.config.options.iter().map(|opt| opt.exchange).collect()
535 };
536
537 let base_url = resolve_ws_base_url(self.config.tardis_ws_url.as_deref())?;
538 let (instrument_map, instruments) = http_client
539 .bootstrap_instruments(&exchanges)
540 .await
541 .map_err(|e| anyhow::anyhow!("Failed to bootstrap instruments: {e}"))?;
542
543 for instrument in instruments {
544 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
545 log::error!("Failed to send instrument event: {e}");
546 }
547 }
548
549 let url = self.build_ws_url(&base_url)?;
550
551 let mode_label = if is_stream_mode { "stream" } else { "replay" };
552 log::info!("Connecting to Tardis Machine {mode_label}");
553 log::debug!("URL: {url}");
554
555 self.cancellation_token = CancellationToken::new();
556
557 let (ws_stream, _) = connect_async(&url)
558 .await
559 .map_err(|e| anyhow::anyhow!("Failed to connect to Tardis Machine: {e}"))?;
560
561 log::info!("Connected to Tardis Machine");
562
563 self.spawn_ws_task(
564 ws_stream,
565 url,
566 instrument_map,
567 book_snapshot_output,
568 extract_bbo_as_quotes,
569 is_stream_mode,
570 );
571 self.is_connected.store(true, Ordering::Release);
572
573 log::info!("Connected: {}", self.client_id);
574 Ok(())
575 }
576
577 async fn disconnect(&mut self) -> anyhow::Result<()> {
578 self.cancellation_token.cancel();
579 self.cancellation_token = CancellationToken::new();
580
581 let handles: Vec<_> = self.tasks.drain(..).collect();
582 if !handles.is_empty() {
583 for handle in handles {
584 if let Err(e) = handle.await {
585 log::error!("Error joining task: {e}");
586 }
587 }
588 log::info!("Disconnected: {}", self.client_id);
589 }
590
591 self.is_connected.store(false, Ordering::Release);
592
593 Ok(())
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use chrono::NaiveDate;
600 use nautilus_common::live::runner::set_data_event_sender;
601 use rstest::rstest;
602
603 use super::*;
604 use crate::{
605 common::{consts::TARDIS_CLIENT_ID, enums::TardisExchange},
606 config::TardisDataClientConfig,
607 machine::types::ReplayNormalizedRequestOptions,
608 };
609
610 fn setup_test_env() {
611 use std::cell::OnceCell;
612
613 thread_local! {
614 static INIT: OnceCell<()> = const { OnceCell::new() };
615 }
616
617 INIT.with(|cell| {
618 cell.get_or_init(|| {
619 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
620 set_data_event_sender(sender);
621 });
622 });
623 }
624
625 #[rstest]
626 fn test_build_ws_url_injects_derivative_ticker() {
627 setup_test_env();
628
629 let config = TardisDataClientConfig {
630 options: vec![ReplayNormalizedRequestOptions {
631 exchange: TardisExchange::BinanceFutures,
632 symbols: Some(vec!["BTCUSDT".to_string()]),
633 from: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
634 to: NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(),
635 data_types: vec!["trade".to_string()],
636 with_disconnect_messages: Some(false),
637 }],
638 ..Default::default()
639 };
640
641 let client = TardisDataClient::new(*TARDIS_CLIENT_ID, config).unwrap();
642 let url = client.build_ws_url("ws://localhost:8001").unwrap();
643
644 assert!(
645 url.contains("derivative_ticker"),
646 "URL should contain derivative_ticker but was: {url}"
647 );
648 assert!(url.contains("trade"), "URL should still contain trade");
649 }
650
651 #[rstest]
652 fn test_build_ws_url_does_not_duplicate_derivative_ticker() {
653 setup_test_env();
654
655 let config = TardisDataClientConfig {
656 options: vec![ReplayNormalizedRequestOptions {
657 exchange: TardisExchange::BinanceFutures,
658 symbols: Some(vec!["BTCUSDT".to_string()]),
659 from: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
660 to: NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(),
661 data_types: vec!["trade".to_string(), "derivative_ticker".to_string()],
662 with_disconnect_messages: Some(false),
663 }],
664 ..Default::default()
665 };
666
667 let client = TardisDataClient::new(*TARDIS_CLIENT_ID, config).unwrap();
668 let ws_url = client.build_ws_url("ws://localhost:8001").unwrap();
669
670 let decoded = urlencoding::decode(ws_url.split("options=").nth(1).unwrap()).unwrap();
671 let count = decoded.matches("derivative_ticker").count();
672 assert_eq!(count, 1, "derivative_ticker should appear exactly once");
673 }
674}