1pub mod cache;
17pub mod client;
18pub mod message;
19pub mod parse;
20pub mod types;
21
22use std::{
23 sync::{
24 Arc,
25 atomic::{AtomicBool, Ordering},
26 },
27 time::Duration,
28};
29
30use async_stream::stream;
31use futures_util::{Sink, SinkExt, Stream, StreamExt, pin_mut};
32use message::{WsMessage, decode_ws_message};
33use nautilus_core::{consts::NAUTILUS_USER_AGENT, string::urlencoding};
34use nautilus_network::url_origin;
35use tokio_tungstenite::{
36 connect_async,
37 tungstenite::{self, client::IntoClientRequest, protocol::frame::coding::CloseCode},
38};
39use tokio_util::sync::CancellationToken;
40use types::{ReplayNormalizedRequestOptions, StreamNormalizedRequestOptions};
41
42use crate::common::enums::TardisExchange;
43pub use crate::machine::client::TardisMachineClient;
44
45pub type Result<T> = std::result::Result<T, Error>;
46
47#[derive(Debug, thiserror::Error)]
49pub enum Error {
50 #[error("Options cannot be empty")]
52 EmptyOptions,
53 #[error("Unsupported Tardis streaming exchange: {0} (historical-only; use replay options)")]
55 UnsupportedStreamingExchange(TardisExchange),
56 #[error("Failed to connect: {0}")]
58 ConnectFailed(#[from] tungstenite::Error),
59 #[error("Connection rejected: {reason}")]
61 ConnectRejected {
62 status: tungstenite::http::StatusCode,
64 reason: String,
66 },
67 #[error("Connection closed: {reason}")]
69 ConnectionClosed {
70 reason: String,
72 },
73 #[error("Failed to deserialize message: {0}")]
75 Deserialization(#[from] serde_json::Error),
76}
77
78#[cfg(any(feature = "replay", feature = "python", test))]
79impl Error {
80 pub(crate) fn log_stream_error(&self) {
81 log::error!("Tardis Machine stream error: {}", self.redacted_message());
82 }
83
84 fn redacted_message(&self) -> String {
85 match self {
86 Self::EmptyOptions | Self::UnsupportedStreamingExchange(_) => self.to_string(),
87 Self::ConnectFailed(tungstenite::Error::Io(error)) => {
88 format!("Failed to connect: I/O error: {:?}", error.kind())
89 }
90 Self::ConnectFailed(tungstenite::Error::Http(response)) => {
91 format!(
92 "Failed to connect: HTTP status {}",
93 response.status().as_u16()
94 )
95 }
96 Self::ConnectFailed(_) => "Failed to connect".to_string(),
97 Self::ConnectRejected { status, .. } => {
98 format!("Connection rejected with status {}", status.as_u16())
99 }
100 Self::ConnectionClosed { reason } if reason == MISSING_INSTRUMENT_INFO => {
101 MISSING_INSTRUMENT_INFO.to_string()
102 }
103 Self::ConnectionClosed { .. } => "Connection closed".to_string(),
104 Self::Deserialization(error) => format!(
105 "Failed to deserialize message at line {}, column {}",
106 error.line(),
107 error.column()
108 ),
109 }
110 }
111}
112
113pub(crate) const MISSING_INSTRUMENT_INFO: &str = "Missing instrument definition info";
114
115pub async fn replay_normalized(
121 base_url: &str,
122 options: Vec<ReplayNormalizedRequestOptions>,
123 signal: Arc<AtomicBool>,
124) -> Result<impl Stream<Item = Result<WsMessage>>> {
125 if options.is_empty() {
126 return Err(Error::EmptyOptions);
127 }
128
129 let path = format!("{base_url}/ws-replay-normalized?options=");
130 let options = serde_json::to_string(&options)?;
131
132 log::debug!("Connecting to {}", url_origin(base_url));
133
134 let url = format!("{path}{}", urlencoding::encode(&options));
135 stream_from_websocket(base_url, url, signal).await
136}
137
138pub async fn stream_normalized(
144 base_url: &str,
145 options: Vec<StreamNormalizedRequestOptions>,
146 signal: Arc<AtomicBool>,
147) -> Result<impl Stream<Item = Result<WsMessage>>> {
148 validate_stream_options(&options)?;
149
150 let path = format!("{base_url}/ws-stream-normalized?options=");
151 let options = serde_json::to_string(&options)?;
152
153 log::debug!("Connecting to {}", url_origin(base_url));
154
155 let url = format!("{path}{}", urlencoding::encode(&options));
156 stream_from_websocket(base_url, url, signal).await
157}
158
159pub(crate) fn validate_stream_options(options: &[StreamNormalizedRequestOptions]) -> Result<()> {
160 if options.is_empty() {
161 return Err(Error::EmptyOptions);
162 }
163
164 for option in options {
165 if option.exchange == TardisExchange::Bitmex {
166 return Err(Error::UnsupportedStreamingExchange(option.exchange));
167 }
168 }
169
170 Ok(())
171}
172
173pub(crate) fn is_unsupported_streaming_error(reason: &str) -> bool {
174 reason
175 .strip_prefix("Error: ")
176 .unwrap_or(reason)
177 .starts_with("Real-time streaming is not supported for exchange ")
178}
179
180async fn stream_from_websocket(
181 base_url: &str,
182 url: String,
183 signal: Arc<AtomicBool>,
184) -> Result<impl Stream<Item = Result<WsMessage>>> {
185 let mut request = url.into_client_request()?;
186 request.headers_mut().insert(
187 tungstenite::http::header::USER_AGENT,
188 tungstenite::http::HeaderValue::from_static(NAUTILUS_USER_AGENT),
189 );
190
191 let (ws_stream, ws_resp) = connect_async(request).await?;
192
193 handle_connection_response(&ws_resp)?;
194 log::debug!("Connected to {}", url_origin(base_url));
195
196 Ok(stream! {
197 let (writer, mut reader) = ws_stream.split();
198 let cancel = CancellationToken::new();
199 let heartbeat = heartbeat(writer, cancel.child_token());
200 pin_mut!(heartbeat);
201 let mut heartbeat_active = true;
202 let _cancel_heartbeat = cancel.drop_guard();
203
204 let timeout = Duration::from_millis(10);
206
207 log::debug!("Streaming from websocket...");
208
209 loop {
210 if signal.load(Ordering::Relaxed) {
211 log::debug!("Shutdown signal received");
212 break;
213 }
214
215 let result = tokio::select! {
216 result = tokio::time::timeout(timeout, reader.next()) => result,
217 () = &mut heartbeat, if heartbeat_active => {
218 heartbeat_active = false;
219 continue;
220 }
221 };
222 let msg = match result {
223 Ok(msg) => msg,
224 Err(_) => continue, };
226
227 match msg {
228 Some(Ok(msg)) => match msg {
229 tungstenite::Message::Frame(_)
230 | tungstenite::Message::Binary(_)
231 | tungstenite::Message::Pong(_)
232 | tungstenite::Message::Ping(_) => {
233 log::trace!("Received WebSocket control or binary frame");
234 }
235 tungstenite::Message::Close(Some(frame)) => {
236 let reason = frame.reason.to_string();
237 if frame.code == CloseCode::Normal {
238 log::debug!("Connection closed normally");
239 } else {
240 log::warn!(
241 "Connection closed abnormally with code: {:?}", frame.code
242 );
243 yield Err(Error::ConnectionClosed { reason });
244 }
245 break;
246 }
247 tungstenite::Message::Close(None) => {
248 log::warn!("Connection closed without a frame");
249 yield Err(Error::ConnectionClosed {
250 reason: "No close frame provided".to_string()
251 });
252 break;
253 }
254 tungstenite::Message::Text(msg) => {
255 match decode_ws_message(&msg) {
256 Ok(parsed_msg) => yield Ok(parsed_msg),
257 Err(e) => {
258 log::error!("Failed to deserialize message at line {}, column {}", e.line(), e.column());
259 yield Err(Error::Deserialization(e));
260 }
261 }
262 }
263 },
264 Some(Err(e)) => {
265 log::warn!("WebSocket error");
266 yield Err(Error::ConnectFailed(e));
267 break;
268 }
269 None => {
270 log::warn!("Connection closed unexpectedly");
271 yield Err(Error::ConnectionClosed {
272 reason: "Unexpected connection close".to_string(),
273 });
274 break;
275 }
276 }
277 }
278
279 log::debug!("Shutdown stream");
280 })
281}
282
283fn handle_connection_response(
284 ws_resp: &tungstenite::http::Response<Option<Vec<u8>>>,
285) -> Result<()> {
286 if ws_resp.status() != tungstenite::http::StatusCode::SWITCHING_PROTOCOLS {
287 return match ws_resp.body() {
288 Some(resp) => Err(Error::ConnectRejected {
289 status: ws_resp.status(),
290 reason: String::from_utf8_lossy(resp).to_string(),
291 }),
292 None => Err(Error::ConnectRejected {
293 status: ws_resp.status(),
294 reason: "Unknown reason".to_string(),
295 }),
296 };
297 }
298 Ok(())
299}
300
301async fn heartbeat<S>(mut sender: S, cancel: CancellationToken)
302where
303 S: Sink<tungstenite::Message> + Unpin,
304 S::Error: std::fmt::Display,
305{
306 let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(10));
307
308 loop {
309 tokio::select! {
310 _ = heartbeat_interval.tick() => {}
311 () = cancel.cancelled() => break,
312 }
313
314 log::trace!("Sending PING");
315
316 tokio::select! {
317 result = sender.send(tungstenite::Message::Ping(vec![].into())) => {
318 if result.is_err() {
319 log::debug!("Heartbeat send failed (connection closed)");
320 break;
321 }
322 }
323 () = cancel.cancelled() => break,
324 }
325 }
326
327 log::debug!("Heartbeat task exiting");
328}
329
330#[cfg(test)]
331mod tests {
332 use std::{
333 convert::Infallible,
334 pin::Pin,
335 task::{Context, Poll},
336 time::Duration,
337 };
338
339 use futures_util::Sink;
340 use rstest::rstest;
341 use tokio_tungstenite::tungstenite::{self, Message};
342 use tokio_util::sync::CancellationToken;
343
344 use super::{
345 Arc, AtomicBool, Error, MISSING_INSTRUMENT_INFO, StreamNormalizedRequestOptions,
346 TardisExchange, heartbeat, stream_normalized,
347 };
348
349 #[allow(
350 dead_code,
351 reason = "shared capture also provides integration-only servers"
352 )]
353 mod client_logging {
354 include!("../../../../network/tests/integration/common/client_logging.rs");
355 }
356
357 #[rstest]
358 #[case(Error::EmptyOptions, "Options cannot be empty")]
359 #[case(
360 Error::UnsupportedStreamingExchange(TardisExchange::Bitmex),
361 "Unsupported Tardis streaming exchange: bitmex (historical-only; use replay options)"
362 )]
363 #[case(
364 Error::ConnectFailed(tungstenite::Error::Io(std::io::Error::new(
365 std::io::ErrorKind::ConnectionRefused,
366 "SYN-secret"
367 ))),
368 "Failed to connect: I/O error: ConnectionRefused"
369 )]
370 #[case(
371 Error::ConnectFailed(tungstenite::Error::ConnectionClosed),
372 "Failed to connect"
373 )]
374 #[case(Error::ConnectFailed(tungstenite::Error::Http(Box::new(tungstenite::http::Response::builder().status(403).body(Some(b"SYN-secret".to_vec())).unwrap()))), "Failed to connect: HTTP status 403")]
375 #[case(Error::ConnectRejected { status: tungstenite::http::StatusCode::UNAUTHORIZED, reason: "SYN-secret".to_string() }, "Connection rejected with status 401")]
376 #[case(Error::ConnectionClosed { reason: "SYN-secret".to_string() }, "Connection closed")]
377 #[case(Error::ConnectionClosed { reason: MISSING_INSTRUMENT_INFO.to_string() }, MISSING_INSTRUMENT_INFO)]
378 #[case(Error::Deserialization(serde_json::from_str::<u64>("\"SYN-secret\"").unwrap_err()), "Failed to deserialize message at line 1, column 12")]
379 #[tokio::test]
380 async fn test_stream_error_logging_omits_payload(#[case] error: Error, #[case] message: &str) {
381 let _capture = client_logging::capture_logs().await;
382 error.log_stream_error();
383 let messages = client_logging::messages();
384 let relevant = messages
385 .iter()
386 .filter(|(_, value)| value.starts_with("Tardis Machine stream error:"))
387 .cloned()
388 .collect::<Vec<_>>();
389 assert_eq!(
390 relevant,
391 vec![(
392 log::Level::Error,
393 format!("Tardis Machine stream error: {message}")
394 )]
395 );
396 assert!(
397 !messages
398 .iter()
399 .any(|(_, value)| value.contains("SYN-secret"))
400 );
401 }
402
403 #[rstest]
404 #[case(vec![TardisExchange::Bitmex])]
405 #[case(vec![TardisExchange::Deribit, TardisExchange::Bitmex])]
406 #[tokio::test]
407 async fn test_stream_rejects_bitmex_before_connect(#[case] exchanges: Vec<TardisExchange>) {
408 let options = exchanges
409 .into_iter()
410 .map(|exchange| StreamNormalizedRequestOptions {
411 exchange,
412 symbols: None,
413 data_types: vec!["trade".to_string()],
414 with_disconnect_messages: None,
415 timeout_interval_ms: None,
416 })
417 .collect();
418
419 let result =
420 stream_normalized("not a URL", options, Arc::new(AtomicBool::new(false))).await;
421
422 assert!(matches!(
423 result,
424 Err(Error::UnsupportedStreamingExchange(TardisExchange::Bitmex))
425 ));
426 }
427
428 #[tokio::test]
429 async fn test_stream_allows_deribit_to_reach_connect() {
430 let options = vec![StreamNormalizedRequestOptions {
431 exchange: TardisExchange::Deribit,
432 symbols: None,
433 data_types: vec!["book_change".to_string()],
434 with_disconnect_messages: None,
435 timeout_interval_ms: None,
436 }];
437
438 let result =
439 stream_normalized("not a URL", options, Arc::new(AtomicBool::new(false))).await;
440
441 assert!(matches!(result, Err(Error::ConnectFailed(_))));
442 }
443
444 struct StallSink;
445
446 impl Sink<Message> for StallSink {
447 type Error = Infallible;
448
449 fn poll_ready(
450 self: Pin<&mut Self>,
451 _cx: &mut Context<'_>,
452 ) -> Poll<Result<(), Self::Error>> {
453 Poll::Pending
454 }
455
456 fn start_send(self: Pin<&mut Self>, _item: Message) -> Result<(), Self::Error> {
457 Ok(())
458 }
459
460 fn poll_flush(
461 self: Pin<&mut Self>,
462 _cx: &mut Context<'_>,
463 ) -> Poll<Result<(), Self::Error>> {
464 Poll::Pending
465 }
466
467 fn poll_close(
468 self: Pin<&mut Self>,
469 _cx: &mut Context<'_>,
470 ) -> Poll<Result<(), Self::Error>> {
471 Poll::Ready(Ok(()))
472 }
473 }
474
475 #[rstest]
476 #[tokio::test]
477 async fn test_heartbeat_exits_on_cancel_during_stalled_send() {
478 let cancel = CancellationToken::new();
479 let task = tokio::spawn(heartbeat(StallSink, cancel.clone()));
480
481 tokio::task::yield_now().await;
482 cancel.cancel();
483
484 tokio::time::timeout(Duration::from_secs(1), task)
485 .await
486 .expect("heartbeat should exit after cancel during a stalled send")
487 .unwrap();
488 }
489}