Skip to main content

nautilus_tardis/machine/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16pub 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/// The error that could happen while interacting with Tardis Machine Server.
48#[derive(Debug, thiserror::Error)]
49pub enum Error {
50    /// An error that could happen when an empty options array was given.
51    #[error("Options cannot be empty")]
52    EmptyOptions,
53    /// An exchange supported for replay but not real-time streaming.
54    #[error("Unsupported Tardis streaming exchange: {0} (historical-only; use replay options)")]
55    UnsupportedStreamingExchange(TardisExchange),
56    /// An error when failed to connect to Tardis' websocket connection.
57    #[error("Failed to connect: {0}")]
58    ConnectFailed(#[from] tungstenite::Error),
59    /// An error when WS connection to the machine server was rejected.
60    #[error("Connection rejected: {reason}")]
61    ConnectRejected {
62        /// The status code for the initial WS connection.
63        status: tungstenite::http::StatusCode,
64        /// The reason why the connection was rejected.
65        reason: String,
66    },
67    /// An error where the websocket connection was closed unexpectedly by Tardis.
68    #[error("Connection closed: {reason}")]
69    ConnectionClosed {
70        /// The reason why the connection was closed.
71        reason: String,
72    },
73    /// An error when deserializing the response from Tardis.
74    #[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
115/// Connects to the Tardis Machine WS replay endpoint and returns a stream of WebSocket messages.
116///
117/// # Errors
118///
119/// Returns an error if no options are provided or the connection fails.
120pub 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
138/// Connects to the Tardis Machine WS streaming endpoint and returns a stream of WebSocket messages.
139///
140/// # Errors
141///
142/// Returns an error if no options are provided, the exchange is BitMEX, or the connection fails.
143pub 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        // Timeout awaiting the next record before checking signal
205        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, // Timeout
225            };
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}