nautilus_testkit/testers/data/
config.rs1use std::num::NonZeroUsize;
17
18use jiff::Timestamp;
19use nautilus_common::{
20 actor::DataActorConfig,
21 config::{ConfigError, ConfigErrorCollector, ConfigResult},
22};
23use nautilus_core::Params;
24use nautilus_model::{
25 data::bar::BarType,
26 enums::BookType,
27 identifiers::{ClientId, InstrumentId},
28};
29use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Deserialize, Serialize, bon::Builder)]
33#[builder(finish_fn(name = build_inner, vis = ""))]
34#[serde(default, deny_unknown_fields)]
35#[cfg_attr(
36 feature = "python",
37 pyo3::pyclass(module = "nautilus_trader.testkit", from_py_object)
38)]
39#[cfg_attr(
40 feature = "python",
41 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.testkit")
42)]
43#[expect(
44 clippy::struct_excessive_bools,
45 reason = "tester configuration exposes independent scenario toggles"
46)]
47#[allow(
48 clippy::unsafe_derive_deserialize,
49 reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
50)]
51pub struct DataTesterConfig {
52 #[builder(default)]
54 pub base: DataActorConfig,
55 #[builder(default)]
57 pub instrument_ids: Vec<InstrumentId>,
58 pub client_id: Option<ClientId>,
60 pub bar_types: Option<Vec<BarType>>,
62 #[builder(default = false)]
64 pub subscribe_book_deltas: bool,
65 #[builder(default = false)]
67 pub subscribe_book_depth: bool,
68 #[builder(default = false)]
70 pub subscribe_book_at_interval: bool,
71 #[builder(default = false)]
73 pub subscribe_quotes: bool,
74 #[builder(default = false)]
76 pub subscribe_trades: bool,
77 #[builder(default = false)]
79 pub subscribe_mark_prices: bool,
80 #[builder(default = false)]
82 pub subscribe_index_prices: bool,
83 #[builder(default = false)]
85 pub subscribe_funding_rates: bool,
86 #[builder(default = false)]
88 pub subscribe_bars: bool,
89 #[builder(default = false)]
91 pub subscribe_instrument: bool,
92 #[builder(default = false)]
94 pub subscribe_instrument_status: bool,
95 #[builder(default = false)]
97 pub subscribe_instrument_close: bool,
98 #[builder(default = false)]
100 pub subscribe_option_greeks: bool,
101 pub subscribe_params: Option<Params>,
103 pub request_params: Option<Params>,
105 #[builder(default = true)]
107 pub can_unsubscribe: bool,
108 #[builder(default = false)]
110 pub request_instruments: bool,
111 #[builder(default = false)]
113 pub request_quotes: bool,
114 #[builder(default = false)]
116 pub request_trades: bool,
117 #[builder(default = false)]
119 pub request_bars: bool,
120 #[builder(default = false)]
122 pub request_book_snapshot: bool,
123 #[builder(default = false)]
126 pub request_book_deltas: bool,
127 #[builder(default = false)]
129 pub request_funding_rates: bool,
130 #[builder(default = BookType::L2_MBP)]
132 pub book_type: BookType,
133 pub book_depth: Option<usize>,
135 #[builder(default = 1000)]
138 pub book_interval_ms: usize,
139 #[builder(default = 10)]
141 pub book_levels_to_print: usize,
142 #[builder(default = true)]
144 pub manage_book: bool,
145 #[builder(default = true)]
147 pub log_data: bool,
148 #[builder(default = 5)]
150 pub stats_interval_secs: u64,
151 pub request_start: Option<Timestamp>,
154 pub request_end: Option<Timestamp>,
156 pub request_limit: Option<NonZeroUsize>,
158}
159
160impl<S: data_tester_config_builder::IsComplete> DataTesterConfigBuilder<S> {
161 pub fn build(self) -> ConfigResult<DataTesterConfig> {
167 let config = self.build_inner();
168 config.validate()?;
169 Ok(config)
170 }
171}
172
173impl DataTesterConfig {
174 pub fn validate(&self) -> ConfigResult<()> {
181 let mut errors = ConfigErrorCollector::new();
182
183 errors.check(
184 self.book_interval_ms > 0,
185 ConfigError::range("book_interval_ms", "must be positive, was 0"),
186 );
187
188 if let Some(book_depth) = self.book_depth {
189 errors.check(
190 book_depth > 0,
191 ConfigError::range("book_depth", "must be positive, was 0"),
192 );
193 }
194
195 if let (Some(start), Some(end)) = (self.request_start, self.request_end) {
196 errors.check(
197 start <= end,
198 ConfigError::range("request_start", "must not be later than request_end"),
199 );
200 }
201
202 errors.into_result()
203 }
204
205 #[must_use]
207 pub fn new(client_id: ClientId, instrument_ids: Vec<InstrumentId>) -> Self {
208 Self {
209 base: DataActorConfig::default(),
210 instrument_ids,
211 client_id: Some(client_id),
212 bar_types: None,
213 subscribe_book_deltas: false,
214 subscribe_book_depth: false,
215 subscribe_book_at_interval: false,
216 subscribe_quotes: false,
217 subscribe_trades: false,
218 subscribe_mark_prices: false,
219 subscribe_index_prices: false,
220 subscribe_funding_rates: false,
221 subscribe_bars: false,
222
223 subscribe_instrument: false,
224 subscribe_instrument_status: false,
225 subscribe_instrument_close: false,
226 subscribe_option_greeks: false,
227 subscribe_params: None,
228 request_params: None,
229 can_unsubscribe: true,
230 request_instruments: false,
231 request_quotes: false,
232 request_trades: false,
233 request_bars: false,
234 request_book_snapshot: false,
235 request_book_deltas: false,
236 request_funding_rates: false,
237 book_type: BookType::L2_MBP,
238 book_depth: None,
239 book_interval_ms: 1000,
240 book_levels_to_print: 10,
241 manage_book: true,
242 log_data: true,
243 stats_interval_secs: 5,
244 request_start: None,
245 request_end: None,
246 request_limit: None,
247 }
248 }
249}
250
251impl Default for DataTesterConfig {
252 fn default() -> Self {
253 Self::builder()
254 .build()
255 .expect("default DataTesterConfig should be valid")
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use rstest::rstest;
262
263 use super::*;
264
265 #[rstest]
266 fn test_default_config_is_valid() {
267 assert!(DataTesterConfig::builder().build().is_ok());
268 }
269
270 #[rstest]
271 fn test_zero_book_interval_ms_rejected() {
272 let result = DataTesterConfig::builder().book_interval_ms(0).build();
273 assert!(
274 matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
275 );
276 }
277
278 #[rstest]
279 fn test_zero_book_depth_rejected() {
280 let result = DataTesterConfig::builder().book_depth(0).build();
281 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
282 }
283
284 #[rstest]
285 #[case(1, true)]
286 #[case(0, true)]
287 #[case(-1, false)]
288 fn test_request_window_validation(#[case] end_seconds: i64, #[case] valid: bool) {
289 let start = Timestamp::from_second(1_700_000_000).unwrap();
290 let end = start + jiff::SignedDuration::from_secs(end_seconds);
291 let result = DataTesterConfig::builder()
292 .request_start(start)
293 .request_end(end)
294 .build();
295
296 if valid {
297 let config = result.unwrap();
298 assert_eq!(config.request_start, Some(start));
299 assert_eq!(config.request_end, Some(end));
300 } else {
301 assert!(matches!(result, Err(ConfigError::Range { field, reason })
302 if field == "request_start" && reason == "must not be later than request_end"));
303 }
304 }
305
306 #[rstest]
307 fn test_request_bounds_serde_roundtrip() {
308 let start = Timestamp::from_second(1_700_000_000).unwrap();
309 let end = Timestamp::from_second(1_700_000_123).unwrap();
310 let limit = NonZeroUsize::new(37).unwrap();
311 let config = DataTesterConfig::builder()
312 .request_start(start)
313 .request_end(end)
314 .request_limit(limit)
315 .build()
316 .unwrap();
317
318 let value = serde_json::to_value(config).unwrap();
319 let decoded: DataTesterConfig = serde_json::from_value(value).unwrap();
320
321 assert_eq!(decoded.request_start, Some(start));
322 assert_eq!(decoded.request_end, Some(end));
323 assert_eq!(decoded.request_limit, Some(limit));
324 }
325}