1use nautilus_common::config::{ConfigResult, check_unique_values};
17#[cfg(test)]
18use nautilus_core::string::secret::REDACTED;
19use nautilus_core::string::secret::SecretString;
20#[cfg(feature = "arrow")]
21use parquet::basic::{Compression, ZstdLevel};
22use serde::{Deserialize, Serialize};
23
24use super::machine::types::{ReplayNormalizedRequestOptions, StreamNormalizedRequestOptions};
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum BookSnapshotOutput {
30 #[default]
32 Deltas,
33 #[serde(alias = "depth10")]
35 Depth,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum ParquetCompression {
42 #[default]
44 Zstd,
45 Snappy,
47 Uncompressed,
49}
50
51#[cfg(feature = "arrow")]
52impl ParquetCompression {
53 #[must_use]
59 pub fn as_parquet_compression(&self) -> Compression {
60 match self {
61 Self::Zstd => {
62 let level = ZstdLevel::try_new(3).expect("zstd level 3 is valid");
63 Compression::ZSTD(level)
64 }
65 Self::Snappy => Compression::SNAPPY,
66 Self::Uncompressed => Compression::UNCOMPRESSED,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
73#[serde(deny_unknown_fields)]
74pub struct TardisReplayConfig {
75 pub tardis_http_url: Option<SecretString>,
77 pub tardis_ws_url: Option<SecretString>,
79 pub proxy_url: Option<SecretString>,
82 pub normalize_symbols: Option<bool>,
84 pub output_path: Option<String>,
86 #[builder(default)]
88 #[serde(default)]
89 pub options: Vec<ReplayNormalizedRequestOptions>,
90 pub book_snapshot_output: Option<BookSnapshotOutput>,
95 pub extract_bbo_as_quotes: Option<bool>,
97 pub compression: Option<ParquetCompression>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
107#[serde(default, deny_unknown_fields)]
108#[cfg_attr(
109 feature = "python",
110 pyo3::pyclass(module = "nautilus_trader.adapters.tardis", from_py_object)
111)]
112#[cfg_attr(
113 feature = "python",
114 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.tardis")
115)]
116pub struct TardisDataClientConfig {
117 pub api_key: Option<SecretString>,
120 pub tardis_http_url: Option<SecretString>,
122 pub tardis_ws_url: Option<SecretString>,
125 pub proxy_url: Option<SecretString>,
128 #[builder(default = true)]
130 pub normalize_symbols: bool,
131 #[builder(default)]
133 pub book_snapshot_output: BookSnapshotOutput,
134 #[builder(default)]
136 pub extract_bbo_as_quotes: bool,
137 #[builder(default)]
140 pub options: Vec<ReplayNormalizedRequestOptions>,
141 #[builder(default)]
145 pub stream_options: Vec<StreamNormalizedRequestOptions>,
146}
147
148#[cfg(feature = "python")]
149nautilus_core::impl_pyo3_config_getters!(TardisDataClientConfig {
150 normalize_symbols: bool,
151 extract_bbo_as_quotes: bool,
152 options: Vec<ReplayNormalizedRequestOptions>,
153 stream_options: Vec<StreamNormalizedRequestOptions>,
154});
155
156impl Default for TardisDataClientConfig {
157 fn default() -> Self {
158 Self::builder().build()
159 }
160}
161
162impl TardisReplayConfig {
163 pub fn validate(&self) -> ConfigResult<()> {
169 validate_replay_options(&self.options)
170 }
171}
172
173impl TardisDataClientConfig {
174 pub fn validate(&self) -> ConfigResult<()> {
180 validate_replay_options(&self.options)?;
181 check_unique_values("stream_options", &self.stream_options)?;
182
183 for (index, option) in self.stream_options.iter().enumerate() {
184 if let Some(symbols) = &option.symbols {
185 check_unique_values(format!("stream_options[{index}].symbols"), symbols)?;
186 }
187
188 check_unique_values(
189 format!("stream_options[{index}].data_types"),
190 &option.data_types,
191 )?;
192 }
193
194 Ok(())
195 }
196}
197
198fn validate_replay_options(options: &[ReplayNormalizedRequestOptions]) -> ConfigResult<()> {
199 check_unique_values("options", options)?;
200
201 for (index, option) in options.iter().enumerate() {
202 if let Some(symbols) = &option.symbols {
203 check_unique_values(format!("options[{index}].symbols"), symbols)?;
204 }
205
206 check_unique_values(format!("options[{index}].data_types"), &option.data_types)?;
207 }
208
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use nautilus_common::config::ConfigError;
215 use rstest::rstest;
216
217 use super::*;
218
219 #[rstest]
220 fn test_default_config_values() {
221 let config = TardisDataClientConfig::default();
222 assert!(config.api_key.is_none());
223 assert!(config.tardis_ws_url.is_none());
224 assert!(config.tardis_http_url.is_none());
225 assert!(config.proxy_url.is_none());
226 assert!(config.normalize_symbols);
227 assert!(matches!(
228 config.book_snapshot_output,
229 BookSnapshotOutput::Deltas
230 ));
231 assert!(!config.extract_bbo_as_quotes);
232 assert!(config.options.is_empty());
233 assert!(config.stream_options.is_empty());
234 }
235
236 #[rstest]
237 fn test_config_debug_redacts_credentials_and_urls() {
238 let config = TardisDataClientConfig {
239 api_key: Some("api-key-value".into()),
240 tardis_ws_url: Some("wss://user:ws-secret@localhost".into()),
241 tardis_http_url: Some("https://user:http-secret@localhost".into()),
242 proxy_url: Some("http://user:proxy-secret@localhost".into()),
243 ..Default::default()
244 };
245
246 let formatted = format!("{config:?}");
247
248 assert_eq!(formatted.matches(REDACTED).count(), 4);
249 assert!(!formatted.contains("api-key-value"));
250 assert!(!formatted.contains("ws-secret"));
251 assert!(!formatted.contains("http-secret"));
252 assert!(!formatted.contains("proxy-secret"));
253 }
254
255 #[rstest]
256 fn test_book_snapshot_output_default_is_deltas() {
257 assert!(matches!(
258 BookSnapshotOutput::default(),
259 BookSnapshotOutput::Deltas
260 ));
261 }
262
263 #[rstest]
264 fn test_book_snapshot_output_serde_roundtrip_deltas() {
265 let json = serde_json::to_string(&BookSnapshotOutput::Deltas).unwrap();
266 assert_eq!(json, "\"deltas\"");
267
268 let deserialized: BookSnapshotOutput = serde_json::from_str(&json).unwrap();
269 assert!(matches!(deserialized, BookSnapshotOutput::Deltas));
270 }
271
272 #[rstest]
273 fn test_book_snapshot_output_serde_roundtrip_depth() {
274 let json = serde_json::to_string(&BookSnapshotOutput::Depth).unwrap();
275 assert_eq!(json, "\"depth\"");
276
277 let deserialized: BookSnapshotOutput = serde_json::from_str(&json).unwrap();
278 assert!(matches!(deserialized, BookSnapshotOutput::Depth));
279 }
280
281 #[rstest]
282 fn test_book_snapshot_output_accepts_legacy_depth10_spelling() {
283 let deserialized: BookSnapshotOutput = serde_json::from_str("\"depth10\"").unwrap();
284 assert!(matches!(deserialized, BookSnapshotOutput::Depth));
285 }
286
287 #[cfg(feature = "arrow")]
288 #[rstest]
289 fn test_parquet_compression_default_is_zstd() {
290 assert!(matches!(
291 ParquetCompression::default(),
292 ParquetCompression::Zstd
293 ));
294 assert!(matches!(
295 ParquetCompression::default().as_parquet_compression(),
296 Compression::ZSTD(_)
297 ));
298 }
299
300 #[cfg(feature = "arrow")]
301 #[rstest]
302 fn test_parquet_compression_serde_roundtrip() {
303 let cases = [
304 (ParquetCompression::Zstd, "\"zstd\""),
305 (ParquetCompression::Snappy, "\"snappy\""),
306 (ParquetCompression::Uncompressed, "\"uncompressed\""),
307 ];
308
309 for (compression, expected_json) in cases {
310 let json = serde_json::to_string(&compression).unwrap();
311 assert_eq!(json, expected_json);
312
313 let deserialized: ParquetCompression = serde_json::from_str(&json).unwrap();
314 assert_eq!(
315 compression.as_parquet_compression(),
316 deserialized.as_parquet_compression()
317 );
318 }
319 }
320
321 #[rstest]
322 fn test_data_client_config_toml_minimal() {
323 let config: TardisDataClientConfig = toml::from_str(
324 r#"
325normalize_symbols = false
326book_snapshot_output = "depth"
327"#,
328 )
329 .unwrap();
330
331 assert!(!config.normalize_symbols);
332 assert!(matches!(
333 config.book_snapshot_output,
334 BookSnapshotOutput::Depth
335 ));
336 assert!(!config.extract_bbo_as_quotes);
337 assert!(config.options.is_empty());
338 }
339
340 #[rstest]
341 fn test_replay_config_omits_options_uses_empty_default() {
342 let config: TardisReplayConfig = toml::from_str(
343 r#"
344tardis_ws_url = "wss://example.com"
345normalize_symbols = false
346"#,
347 )
348 .unwrap();
349
350 assert!(config.options.is_empty());
351 assert_eq!(
352 config
353 .tardis_ws_url
354 .as_ref()
355 .map(SecretString::expose_secret),
356 Some("wss://example.com")
357 );
358 assert_eq!(config.normalize_symbols, Some(false));
359 assert_eq!(config.extract_bbo_as_quotes, None);
360 }
361
362 #[rstest]
363 fn test_replay_config_deserializes_compression() {
364 let json = r#"{
365 "tardis_ws_url": null,
366 "normalize_symbols": true,
367 "output_path": null,
368 "options": [],
369 "proxy_url": null,
370 "book_snapshot_output": "depth",
371 "extract_bbo_as_quotes": true,
372 "compression": "zstd"
373 }"#;
374
375 let config: TardisReplayConfig = serde_json::from_str(json).unwrap();
376
377 assert!(matches!(
378 config.book_snapshot_output,
379 Some(BookSnapshotOutput::Depth)
380 ));
381 assert_eq!(config.extract_bbo_as_quotes, Some(true));
382 assert!(matches!(config.compression, Some(ParquetCompression::Zstd)));
383 }
384
385 #[rstest]
386 #[case::replay_symbols(false, "symbols")]
387 #[case::replay_types(false, "data_types")]
388 #[case::replay_options(false, "options")]
389 #[case::stream_symbols(true, "symbols")]
390 #[case::stream_types(true, "data_types")]
391 #[case::stream_options(true, "stream_options")]
392 fn test_option_selection_duplicates(#[case] stream: bool, #[case] field: &str) {
393 let mut option = serde_json::json!({
394 "exchange": "binance-futures", "symbols": ["BTCUSDT"],
395 "dataTypes": ["trade"], "from": "2024-01-01", "to": "2024-01-02"
396 });
397
398 if stream {
399 option.as_object_mut().unwrap().remove("from");
400 option.as_object_mut().unwrap().remove("to");
401 }
402
403 match field {
404 "symbols" => option["symbols"] = serde_json::json!(["BTCUSDT", "ETHUSDT", "BTCUSDT"]),
405 "data_types" => option["dataTypes"] = serde_json::json!(["trade", "quote", "trade"]),
406 _ => {}
407 }
408
409 let mut options = vec![option.clone()];
410 if field == "options" || field == "stream_options" {
411 options.push(option);
412 }
413
414 let key = if stream { "stream_options" } else { "options" };
415 let mut input = serde_json::json!({});
416 input[key] = serde_json::json!(options);
417 let config: TardisDataClientConfig = serde_json::from_value(input.clone()).unwrap();
418
419 let expected_field = if field == key {
420 key.to_string()
421 } else {
422 format!("{key}[0].{field}")
423 };
424
425 let expected = Err(ConfigError::duplicate(expected_field, None));
426 let result = config.validate();
427
428 let replay_result = if stream {
429 None
430 } else {
431 Some(
432 serde_json::from_value::<TardisReplayConfig>(input)
433 .unwrap()
434 .validate(),
435 )
436 };
437
438 assert_eq!(result, expected);
439
440 if let Some(result) = replay_result {
441 assert_eq!(result, expected);
442 }
443 }
444}