Skip to main content

nautilus_tardis/
config.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
16#[cfg(test)]
17use nautilus_core::string::secret::REDACTED;
18use nautilus_core::string::secret::SecretString;
19#[cfg(feature = "arrow")]
20use parquet::basic::{Compression, ZstdLevel};
21use serde::{Deserialize, Serialize};
22
23use super::machine::types::{ReplayNormalizedRequestOptions, StreamNormalizedRequestOptions};
24
25/// Determines the output format for Tardis `book_snapshot_*` messages.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum BookSnapshotOutput {
29    /// Convert book snapshots to `OrderBookDeltas` and write to `order_book_deltas/`.
30    #[default]
31    Deltas,
32    /// Convert book snapshots to `OrderBookDepth` and write to `order_book_depths/`.
33    #[serde(alias = "depth10")]
34    Depth,
35}
36
37/// Determines the compression codec for Parquet files written by Tardis replay.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ParquetCompression {
41    /// Use Zstandard compression with level 3.
42    #[default]
43    Zstd,
44    /// Use Snappy compression.
45    Snappy,
46    /// Write uncompressed Parquet files.
47    Uncompressed,
48}
49
50#[cfg(feature = "arrow")]
51impl ParquetCompression {
52    /// Converts the replay config compression value to a Parquet compression value.
53    ///
54    /// # Panics
55    ///
56    /// Panics if the hard-coded Zstandard level 3 is rejected by the Parquet crate.
57    #[must_use]
58    pub fn as_parquet_compression(&self) -> Compression {
59        match self {
60            Self::Zstd => {
61                let level = ZstdLevel::try_new(3).expect("zstd level 3 is valid");
62                Compression::ZSTD(level)
63            }
64            Self::Snappy => Compression::SNAPPY,
65            Self::Uncompressed => Compression::UNCOMPRESSED,
66        }
67    }
68}
69
70/// Provides a configuration for a Tardis Machine -> Nautilus data -> Parquet replay run.
71#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
72#[serde(deny_unknown_fields)]
73pub struct TardisReplayConfig {
74    /// The Tardis HTTP API base URL override.
75    pub tardis_http_url: Option<SecretString>,
76    /// The Tardis Machine websocket url.
77    pub tardis_ws_url: Option<SecretString>,
78    /// Optional proxy URL for the Tardis HTTP API client.
79    /// The Tardis Machine WebSocket transport does not yet support proxying.
80    pub proxy_url: Option<SecretString>,
81    /// If symbols should be normalized with Nautilus conventions.
82    pub normalize_symbols: Option<bool>,
83    /// The output directory for writing Nautilus format Parquet files.
84    pub output_path: Option<String>,
85    /// The Tardis Machine replay options.
86    #[builder(default)]
87    #[serde(default)]
88    pub options: Vec<ReplayNormalizedRequestOptions>,
89    /// The output format for `book_snapshot_*` messages.
90    ///
91    /// - `deltas`: Convert to `OrderBookDeltas` and write to `order_book_deltas/` (default).
92    /// - `depth`: Convert to `OrderBookDepth` and write to `order_book_depths/`.
93    pub book_snapshot_output: Option<BookSnapshotOutput>,
94    /// If best bid/offer fields from Tardis `option_summary` messages should emit `QuoteTick`.
95    pub extract_bbo_as_quotes: Option<bool>,
96    /// The compression codec for written data files.
97    ///
98    /// - `zstd`: Use Zstandard compression level 3 (default).
99    /// - `snappy`: Use Snappy compression.
100    /// - `uncompressed`: Write uncompressed Parquet files.
101    pub compression: Option<ParquetCompression>,
102}
103
104/// Configuration for the Tardis data client.
105#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
106#[serde(default, deny_unknown_fields)]
107#[cfg_attr(
108    feature = "python",
109    pyo3::pyclass(module = "nautilus_trader.adapters.tardis", from_py_object)
110)]
111#[cfg_attr(
112    feature = "python",
113    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.tardis")
114)]
115pub struct TardisDataClientConfig {
116    /// Tardis API key for HTTP instrument fetching.
117    /// Falls back to `TARDIS_API_KEY` env var if not set.
118    pub api_key: Option<SecretString>,
119    /// The Tardis HTTP API base URL override.
120    pub tardis_http_url: Option<SecretString>,
121    /// Tardis Machine Server WebSocket URL.
122    /// Falls back to `TARDIS_MACHINE_WS_URL` env var if not set.
123    pub tardis_ws_url: Option<SecretString>,
124    /// Optional proxy URL for the Tardis HTTP API client.
125    /// The Tardis Machine WebSocket transport does not yet support proxying.
126    pub proxy_url: Option<SecretString>,
127    /// Whether to normalize symbols to Nautilus conventions.
128    #[builder(default = true)]
129    pub normalize_symbols: bool,
130    /// Output format for `book_snapshot_*` messages.
131    #[builder(default)]
132    pub book_snapshot_output: BookSnapshotOutput,
133    /// Whether to emit `QuoteTick` from Tardis `option_summary` best bid/offer fields.
134    #[builder(default)]
135    pub extract_bbo_as_quotes: bool,
136    /// Replay options defining exchanges, symbols, date ranges, and data types.
137    /// When non-empty the client connects to `ws-replay-normalized`.
138    #[builder(default)]
139    pub options: Vec<ReplayNormalizedRequestOptions>,
140    /// Live stream options defining exchanges, symbols, and data types.
141    /// When non-empty (and `options` is empty) the client connects to
142    /// `ws-stream-normalized` with automatic reconnection.
143    #[builder(default)]
144    pub stream_options: Vec<StreamNormalizedRequestOptions>,
145}
146
147#[cfg(feature = "python")]
148nautilus_core::impl_pyo3_config_getters!(TardisDataClientConfig {
149    normalize_symbols: bool,
150    extract_bbo_as_quotes: bool,
151    options: Vec<ReplayNormalizedRequestOptions>,
152    stream_options: Vec<StreamNormalizedRequestOptions>,
153});
154
155impl Default for TardisDataClientConfig {
156    fn default() -> Self {
157        Self::builder().build()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use rstest::rstest;
164
165    use super::*;
166
167    #[rstest]
168    fn test_default_config_values() {
169        let config = TardisDataClientConfig::default();
170        assert!(config.api_key.is_none());
171        assert!(config.tardis_ws_url.is_none());
172        assert!(config.tardis_http_url.is_none());
173        assert!(config.proxy_url.is_none());
174        assert!(config.normalize_symbols);
175        assert!(matches!(
176            config.book_snapshot_output,
177            BookSnapshotOutput::Deltas
178        ));
179        assert!(!config.extract_bbo_as_quotes);
180        assert!(config.options.is_empty());
181        assert!(config.stream_options.is_empty());
182    }
183
184    #[rstest]
185    fn test_config_debug_redacts_credentials_and_urls() {
186        let config = TardisDataClientConfig {
187            api_key: Some("api-key-value".into()),
188            tardis_ws_url: Some("wss://user:ws-secret@localhost".into()),
189            tardis_http_url: Some("https://user:http-secret@localhost".into()),
190            proxy_url: Some("http://user:proxy-secret@localhost".into()),
191            ..Default::default()
192        };
193
194        let formatted = format!("{config:?}");
195
196        assert_eq!(formatted.matches(REDACTED).count(), 4);
197        assert!(!formatted.contains("api-key-value"));
198        assert!(!formatted.contains("ws-secret"));
199        assert!(!formatted.contains("http-secret"));
200        assert!(!formatted.contains("proxy-secret"));
201    }
202
203    #[rstest]
204    fn test_book_snapshot_output_default_is_deltas() {
205        assert!(matches!(
206            BookSnapshotOutput::default(),
207            BookSnapshotOutput::Deltas
208        ));
209    }
210
211    #[rstest]
212    fn test_book_snapshot_output_serde_roundtrip_deltas() {
213        let json = serde_json::to_string(&BookSnapshotOutput::Deltas).unwrap();
214        assert_eq!(json, "\"deltas\"");
215
216        let deserialized: BookSnapshotOutput = serde_json::from_str(&json).unwrap();
217        assert!(matches!(deserialized, BookSnapshotOutput::Deltas));
218    }
219
220    #[rstest]
221    fn test_book_snapshot_output_serde_roundtrip_depth() {
222        let json = serde_json::to_string(&BookSnapshotOutput::Depth).unwrap();
223        assert_eq!(json, "\"depth\"");
224
225        let deserialized: BookSnapshotOutput = serde_json::from_str(&json).unwrap();
226        assert!(matches!(deserialized, BookSnapshotOutput::Depth));
227    }
228
229    #[rstest]
230    fn test_book_snapshot_output_accepts_legacy_depth10_spelling() {
231        let deserialized: BookSnapshotOutput = serde_json::from_str("\"depth10\"").unwrap();
232        assert!(matches!(deserialized, BookSnapshotOutput::Depth));
233    }
234
235    #[cfg(feature = "arrow")]
236    #[rstest]
237    fn test_parquet_compression_default_is_zstd() {
238        assert!(matches!(
239            ParquetCompression::default(),
240            ParquetCompression::Zstd
241        ));
242        assert!(matches!(
243            ParquetCompression::default().as_parquet_compression(),
244            Compression::ZSTD(_)
245        ));
246    }
247
248    #[cfg(feature = "arrow")]
249    #[rstest]
250    fn test_parquet_compression_serde_roundtrip() {
251        let cases = [
252            (ParquetCompression::Zstd, "\"zstd\""),
253            (ParquetCompression::Snappy, "\"snappy\""),
254            (ParquetCompression::Uncompressed, "\"uncompressed\""),
255        ];
256
257        for (compression, expected_json) in cases {
258            let json = serde_json::to_string(&compression).unwrap();
259            assert_eq!(json, expected_json);
260
261            let deserialized: ParquetCompression = serde_json::from_str(&json).unwrap();
262            assert_eq!(
263                compression.as_parquet_compression(),
264                deserialized.as_parquet_compression()
265            );
266        }
267    }
268
269    #[rstest]
270    fn test_data_client_config_toml_minimal() {
271        let config: TardisDataClientConfig = toml::from_str(
272            r#"
273normalize_symbols = false
274book_snapshot_output = "depth"
275"#,
276        )
277        .unwrap();
278
279        assert!(!config.normalize_symbols);
280        assert!(matches!(
281            config.book_snapshot_output,
282            BookSnapshotOutput::Depth
283        ));
284        assert!(!config.extract_bbo_as_quotes);
285        assert!(config.options.is_empty());
286    }
287
288    #[rstest]
289    fn test_replay_config_omits_options_uses_empty_default() {
290        let config: TardisReplayConfig = toml::from_str(
291            r#"
292tardis_ws_url = "wss://example.com"
293normalize_symbols = false
294"#,
295        )
296        .unwrap();
297
298        assert!(config.options.is_empty());
299        assert_eq!(
300            config
301                .tardis_ws_url
302                .as_ref()
303                .map(SecretString::expose_secret),
304            Some("wss://example.com")
305        );
306        assert_eq!(config.normalize_symbols, Some(false));
307        assert_eq!(config.extract_bbo_as_quotes, None);
308    }
309
310    #[rstest]
311    fn test_replay_config_deserializes_compression() {
312        let json = r#"{
313            "tardis_ws_url": null,
314            "normalize_symbols": true,
315            "output_path": null,
316            "options": [],
317            "proxy_url": null,
318            "book_snapshot_output": "depth",
319            "extract_bbo_as_quotes": true,
320            "compression": "zstd"
321        }"#;
322
323        let config: TardisReplayConfig = serde_json::from_str(json).unwrap();
324
325        assert!(matches!(
326            config.book_snapshot_output,
327            Some(BookSnapshotOutput::Depth)
328        ));
329        assert_eq!(config.extract_bbo_as_quotes, Some(true));
330        assert!(matches!(config.compression, Some(ParquetCompression::Zstd)));
331    }
332}