1#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum BookSnapshotOutput {
29 #[default]
31 Deltas,
32 #[serde(alias = "depth10")]
34 Depth,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ParquetCompression {
41 #[default]
43 Zstd,
44 Snappy,
46 Uncompressed,
48}
49
50#[cfg(feature = "arrow")]
51impl ParquetCompression {
52 #[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#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
72#[serde(deny_unknown_fields)]
73pub struct TardisReplayConfig {
74 pub tardis_http_url: Option<SecretString>,
76 pub tardis_ws_url: Option<SecretString>,
78 pub proxy_url: Option<SecretString>,
81 pub normalize_symbols: Option<bool>,
83 pub output_path: Option<String>,
85 #[builder(default)]
87 #[serde(default)]
88 pub options: Vec<ReplayNormalizedRequestOptions>,
89 pub book_snapshot_output: Option<BookSnapshotOutput>,
94 pub extract_bbo_as_quotes: Option<bool>,
96 pub compression: Option<ParquetCompression>,
102}
103
104#[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 pub api_key: Option<SecretString>,
119 pub tardis_http_url: Option<SecretString>,
121 pub tardis_ws_url: Option<SecretString>,
124 pub proxy_url: Option<SecretString>,
127 #[builder(default = true)]
129 pub normalize_symbols: bool,
130 #[builder(default)]
132 pub book_snapshot_output: BookSnapshotOutput,
133 #[builder(default)]
135 pub extract_bbo_as_quotes: bool,
136 #[builder(default)]
139 pub options: Vec<ReplayNormalizedRequestOptions>,
140 #[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}