1use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
17use nautilus_core::Params;
18use nautilus_model::{
19 enums::{BookType, ContingencyType, OrderType, TimeInForce, TrailingOffsetType, TriggerType},
20 identifiers::{ClientId, InstrumentId, StrategyId},
21 types::Quantity,
22};
23use nautilus_trading::strategy::StrategyConfig;
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Deserialize, Serialize, bon::Builder)]
29#[builder(finish_fn(name = build_inner, vis = ""))]
30#[serde(default, deny_unknown_fields)]
31#[cfg_attr(
32 feature = "python",
33 pyo3::pyclass(module = "nautilus_trader.testkit", from_py_object)
34)]
35#[cfg_attr(
36 feature = "python",
37 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.testkit")
38)]
39#[expect(
40 clippy::struct_excessive_bools,
41 reason = "tester configuration exposes independent execution scenario toggles"
42)]
43#[allow(
44 clippy::unsafe_derive_deserialize,
45 reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
46)]
47pub struct ExecTesterConfig {
48 #[builder(default)]
50 pub base: StrategyConfig,
51 #[builder(default = InstrumentId::from("BTCUSDT-PERP.BINANCE"))]
53 pub instrument_id: InstrumentId,
54 #[builder(default = Quantity::from("0.001"))]
56 pub order_qty: Quantity,
57 pub order_display_qty: Option<Quantity>,
59 pub order_expire_time_delta_mins: Option<u64>,
61 pub order_params: Option<Params>,
63 pub client_id: Option<ClientId>,
65 #[builder(default = false)]
67 pub subscribe_book: bool,
68 #[builder(default = true)]
70 pub subscribe_quotes: bool,
71 #[builder(default = true)]
73 pub subscribe_trades: bool,
74 #[builder(default = BookType::L2_MBP)]
76 pub book_type: BookType,
77 pub book_depth: Option<usize>,
79 #[builder(default = 1000)]
81 pub book_interval_ms: usize,
82 #[builder(default = 10)]
84 pub book_levels_to_print: usize,
85 pub open_position_on_start_qty: Option<Decimal>,
87 #[builder(default = false)]
89 pub open_position_on_first_quote: bool,
90 #[builder(default = TimeInForce::Gtc)]
92 pub open_position_time_in_force: TimeInForce,
93 #[builder(default = true)]
95 pub enable_limit_buys: bool,
96 #[builder(default = true)]
98 pub enable_limit_sells: bool,
99 #[builder(default = false)]
101 pub enable_stop_buys: bool,
102 #[builder(default = false)]
104 pub enable_stop_sells: bool,
105 #[builder(default = 500)]
107 pub tob_offset_ticks: u64,
108 pub limit_time_in_force: Option<TimeInForce>,
110 #[builder(default = OrderType::StopMarket)]
112 pub stop_order_type: OrderType,
113 #[builder(default = 100)]
115 pub stop_offset_ticks: u64,
116 pub stop_limit_offset_ticks: Option<u64>,
118 #[builder(default = TriggerType::Default)]
120 pub stop_trigger_type: TriggerType,
121 pub stop_time_in_force: Option<TimeInForce>,
123 pub trailing_offset: Option<Decimal>,
125 #[builder(default = TrailingOffsetType::BasisPoints)]
127 pub trailing_offset_type: TrailingOffsetType,
128 #[builder(default = false)]
130 pub enable_brackets: bool,
131 #[builder(default = false)]
133 pub batch_submit_limit_pair: bool,
134 #[builder(default = OrderType::Limit)]
136 pub bracket_entry_order_type: OrderType,
137 #[builder(default = 500)]
139 pub bracket_offset_ticks: u64,
140 #[builder(default = false)]
142 pub modify_orders_to_maintain_tob_offset: bool,
143 #[builder(default = false)]
145 pub modify_stop_orders_to_maintain_offset: bool,
146 #[builder(default = false)]
148 pub cancel_replace_orders_to_maintain_tob_offset: bool,
149 #[builder(default = false)]
151 pub cancel_replace_stop_orders_to_maintain_offset: bool,
152 #[builder(default = false)]
155 pub trigger_limit_order_maintenance_once: bool,
156 #[builder(default = false)]
158 pub use_post_only: bool,
159 #[builder(default = false)]
164 pub limit_aggressive: bool,
165 #[builder(default = false)]
167 pub use_quote_quantity: bool,
168 pub emulation_trigger: Option<TriggerType>,
170 #[builder(default = true)]
172 pub cancel_orders_on_stop: bool,
173 #[builder(default = true)]
175 pub close_positions_on_stop: bool,
176 pub close_positions_qty_precision: Option<u8>,
178 pub close_positions_time_in_force: Option<TimeInForce>,
180 #[builder(default = true)]
182 pub reduce_only_on_stop: bool,
183 #[builder(default = false)]
185 pub use_individual_cancels_on_stop: bool,
186 #[builder(default = false)]
188 pub use_batch_cancel_on_stop: bool,
189 #[builder(default = false)]
191 pub dry_run: bool,
192 #[builder(default = true)]
194 pub log_data: bool,
195 #[builder(default = false)]
197 pub test_reject_post_only: bool,
198 #[builder(default = false)]
200 pub test_reject_reduce_only: bool,
201 #[builder(default = false)]
208 pub test_modify_rejected: bool,
209 #[builder(default = true)]
211 pub can_unsubscribe: bool,
212 #[builder(default = false)]
214 pub clamp_to_instrument_price_range: bool,
215 #[builder(default = ContingencyType::Ouo)]
217 pub bracket_contingency_type: ContingencyType,
218 #[builder(default = OrderType::Limit)]
220 pub bracket_tp_order_type: OrderType,
221}
222
223impl<S: exec_tester_config_builder::IsComplete> ExecTesterConfigBuilder<S> {
224 pub fn build(self) -> ConfigResult<ExecTesterConfig> {
230 let config = self.build_inner();
231 config.validate()?;
232 Ok(config)
233 }
234}
235
236impl ExecTesterConfig {
237 pub fn validate(&self) -> ConfigResult<()> {
244 let mut errors = ConfigErrorCollector::new();
245
246 errors.check(
247 self.book_interval_ms > 0,
248 ConfigError::range("book_interval_ms", "must be positive, was 0"),
249 );
250
251 if let Some(book_depth) = self.book_depth {
252 errors.check(
253 book_depth > 0,
254 ConfigError::range("book_depth", "must be positive, was 0"),
255 );
256 }
257
258 if self.trigger_limit_order_maintenance_once {
259 let modify = self.modify_orders_to_maintain_tob_offset;
260 let cancel_replace = self.cancel_replace_orders_to_maintain_tob_offset;
261
262 errors.check(
263 modify || cancel_replace,
264 ConfigError::required_one_of([
265 "modify_orders_to_maintain_tob_offset",
266 "cancel_replace_orders_to_maintain_tob_offset",
267 ]),
268 );
269 errors.check(
270 !(modify && cancel_replace),
271 ConfigError::mutually_exclusive_fields([
272 "modify_orders_to_maintain_tob_offset",
273 "cancel_replace_orders_to_maintain_tob_offset",
274 ]),
275 );
276 errors.check(
277 self.enable_limit_buys || self.enable_limit_sells,
278 ConfigError::dependency(
279 "trigger_limit_order_maintenance_once",
280 "enable_limit_buys or enable_limit_sells",
281 "at least one limit side must be enabled",
282 ),
283 );
284 errors.check(
285 !(self.batch_submit_limit_pair
286 && self.enable_limit_buys
287 && self.enable_limit_sells),
288 ConfigError::mutually_exclusive_fields([
289 "trigger_limit_order_maintenance_once",
290 "batch_submit_limit_pair",
291 ]),
292 );
293 errors.check(
294 !self.enable_brackets,
295 ConfigError::mutually_exclusive_fields([
296 "trigger_limit_order_maintenance_once",
297 "enable_brackets",
298 ]),
299 );
300 errors.check(
301 !self.test_modify_rejected,
302 ConfigError::mutually_exclusive_fields([
303 "trigger_limit_order_maintenance_once",
304 "test_modify_rejected",
305 ]),
306 );
307 }
308
309 errors.into_result()
310 }
311
312 #[must_use]
314 pub fn new(
315 strategy_id: StrategyId,
316 instrument_id: InstrumentId,
317 client_id: ClientId,
318 order_qty: Quantity,
319 ) -> Self {
320 Self {
321 base: StrategyConfig {
322 strategy_id: Some(strategy_id),
323 order_id_tag: None,
324 ..Default::default()
325 },
326 instrument_id,
327 order_qty,
328 order_display_qty: None,
329 order_expire_time_delta_mins: None,
330 order_params: None,
331 client_id: Some(client_id),
332 subscribe_quotes: true,
333 subscribe_trades: true,
334 subscribe_book: false,
335 book_type: BookType::L2_MBP,
336 book_depth: None,
337 book_interval_ms: 1000,
338 book_levels_to_print: 10,
339 open_position_on_start_qty: None,
340 open_position_on_first_quote: false,
341 open_position_time_in_force: TimeInForce::Gtc,
342 enable_limit_buys: true,
343 enable_limit_sells: true,
344 enable_stop_buys: false,
345 enable_stop_sells: false,
346 tob_offset_ticks: 500,
347 limit_time_in_force: None,
348 stop_order_type: OrderType::StopMarket,
349 stop_offset_ticks: 100,
350 stop_limit_offset_ticks: None,
351 stop_trigger_type: TriggerType::Default,
352 stop_time_in_force: None,
353 trailing_offset: None,
354 trailing_offset_type: TrailingOffsetType::BasisPoints,
355 enable_brackets: false,
356 batch_submit_limit_pair: false,
357 bracket_entry_order_type: OrderType::Limit,
358 bracket_offset_ticks: 500,
359 modify_orders_to_maintain_tob_offset: false,
360 modify_stop_orders_to_maintain_offset: false,
361 cancel_replace_orders_to_maintain_tob_offset: false,
362 cancel_replace_stop_orders_to_maintain_offset: false,
363 trigger_limit_order_maintenance_once: false,
364 use_post_only: false,
365 limit_aggressive: false,
366 use_quote_quantity: false,
367 emulation_trigger: None,
368 cancel_orders_on_stop: true,
369 close_positions_on_stop: true,
370 close_positions_qty_precision: None,
371 close_positions_time_in_force: None,
372 reduce_only_on_stop: true,
373 use_individual_cancels_on_stop: false,
374 use_batch_cancel_on_stop: false,
375 dry_run: false,
376 log_data: true,
377 test_reject_post_only: false,
378 test_reject_reduce_only: false,
379 test_modify_rejected: false,
380 can_unsubscribe: true,
381 clamp_to_instrument_price_range: false,
382 bracket_contingency_type: ContingencyType::Ouo,
383 bracket_tp_order_type: OrderType::Limit,
384 }
385 }
386}
387
388impl Default for ExecTesterConfig {
389 fn default() -> Self {
390 Self::builder()
391 .build()
392 .expect("default ExecTesterConfig should be valid")
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use rstest::rstest;
399
400 use super::*;
401
402 #[rstest]
403 fn test_default_config_is_valid() {
404 assert!(ExecTesterConfig::builder().build().is_ok());
405 }
406
407 #[rstest]
408 fn test_zero_book_interval_ms_rejected() {
409 let result = ExecTesterConfig::builder().book_interval_ms(0).build();
410 assert!(
411 matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
412 );
413 }
414
415 #[rstest]
416 fn test_zero_book_depth_rejected() {
417 let result = ExecTesterConfig::builder().book_depth(0).build();
418 assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
419 }
420}