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