1#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BookSnapshotOutput {
28 #[default]
30 Deltas,
31 #[serde(alias = "depth10")]
33 Depth,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ParquetCompression {
40 #[default]
42 Zstd,
43 Snappy,
45 Uncompressed,
47}
48
49impl ParquetCompression {
50 #[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#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
70#[serde(deny_unknown_fields)]
71pub struct TardisReplayConfig {
72 pub tardis_http_url: Option<SecretString>,
74 pub tardis_ws_url: Option<SecretString>,
76 pub proxy_url: Option<SecretString>,
79 pub normalize_symbols: Option<bool>,
81 pub output_path: Option<String>,
83 #[builder(default)]
85 #[serde(default)]
86 pub options: Vec<ReplayNormalizedRequestOptions>,
87 pub book_snapshot_output: Option<BookSnapshotOutput>,
92 pub extract_bbo_as_quotes: Option<bool>,
94 pub compression: Option<ParquetCompression>,
100}
101
102#[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 pub api_key: Option<SecretString>,
117 pub tardis_http_url: Option<SecretString>,
119 pub tardis_ws_url: Option<SecretString>,
122 pub proxy_url: Option<SecretString>,
125 #[builder(default = true)]
127 pub normalize_symbols: bool,
128 #[builder(default)]
130 pub book_snapshot_output: BookSnapshotOutput,
131 #[builder(default)]
133 pub extract_bbo_as_quotes: bool,
134 #[builder(default)]
137 pub options: Vec<ReplayNormalizedRequestOptions>,
138 #[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}