Skip to main content

nautilus_testkit/testers/exec/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
17use nautilus_core::Params;
18use nautilus_model::{
19    enums::{BookType, 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/// Configuration for the execution tester strategy.
28#[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    /// Base strategy configuration.
49    #[builder(default)]
50    pub base: StrategyConfig,
51    /// Instrument ID to test.
52    #[builder(default = InstrumentId::from("BTCUSDT-PERP.BINANCE"))]
53    pub instrument_id: InstrumentId,
54    /// Order quantity.
55    #[builder(default = Quantity::from("0.001"))]
56    pub order_qty: Quantity,
57    /// Display quantity for iceberg orders (None for full display, Some(0) for hidden).
58    pub order_display_qty: Option<Quantity>,
59    /// Minutes until GTD orders expire (None for GTC).
60    pub order_expire_time_delta_mins: Option<u64>,
61    /// Adapter-specific order parameters.
62    pub order_params: Option<Params>,
63    /// Client ID to use for orders and subscriptions.
64    pub client_id: Option<ClientId>,
65    /// Whether to subscribe to order book.
66    #[builder(default = false)]
67    pub subscribe_book: bool,
68    /// Whether to subscribe to quotes.
69    #[builder(default = true)]
70    pub subscribe_quotes: bool,
71    /// Whether to subscribe to trades.
72    #[builder(default = true)]
73    pub subscribe_trades: bool,
74    /// Book type for order book subscriptions.
75    #[builder(default = BookType::L2_MBP)]
76    pub book_type: BookType,
77    /// Order book depth for subscriptions.
78    pub book_depth: Option<usize>,
79    /// Order book interval in milliseconds.
80    #[builder(default = 1000)]
81    pub book_interval_ms: usize,
82    /// Number of order book levels to print when logging.
83    #[builder(default = 10)]
84    pub book_levels_to_print: usize,
85    /// Quantity to open position on start (positive for buy, negative for sell).
86    pub open_position_on_start_qty: Option<Decimal>,
87    /// Delay opening the start position until the first quote arrives.
88    #[builder(default = false)]
89    pub open_position_on_first_quote: bool,
90    /// Time in force for opening position order.
91    #[builder(default = TimeInForce::Gtc)]
92    pub open_position_time_in_force: TimeInForce,
93    /// Enable limit buy orders.
94    #[builder(default = true)]
95    pub enable_limit_buys: bool,
96    /// Enable limit sell orders.
97    #[builder(default = true)]
98    pub enable_limit_sells: bool,
99    /// Enable stop buy orders.
100    #[builder(default = false)]
101    pub enable_stop_buys: bool,
102    /// Enable stop sell orders.
103    #[builder(default = false)]
104    pub enable_stop_sells: bool,
105    /// Offset from TOB in price ticks for limit orders.
106    #[builder(default = 500)]
107    pub tob_offset_ticks: u64,
108    /// Override time in force for limit orders (None uses GTC/GTD logic).
109    pub limit_time_in_force: Option<TimeInForce>,
110    /// Type of stop order (`STOP_MARKET`, `STOP_LIMIT`, `MARKET_IF_TOUCHED`, `LIMIT_IF_TOUCHED`).
111    #[builder(default = OrderType::StopMarket)]
112    pub stop_order_type: OrderType,
113    /// Offset from market in price ticks for stop trigger.
114    #[builder(default = 100)]
115    pub stop_offset_ticks: u64,
116    /// Offset from trigger price in ticks for stop limit price.
117    pub stop_limit_offset_ticks: Option<u64>,
118    /// Trigger type for stop orders.
119    #[builder(default = TriggerType::Default)]
120    pub stop_trigger_type: TriggerType,
121    /// Override time in force for stop orders (None uses GTC/GTD logic).
122    pub stop_time_in_force: Option<TimeInForce>,
123    /// Trailing offset for `TRAILING_STOP_MARKET` orders.
124    pub trailing_offset: Option<Decimal>,
125    /// Trailing offset type (`BasisPoints` or `Price`).
126    #[builder(default = TrailingOffsetType::BasisPoints)]
127    pub trailing_offset_type: TrailingOffsetType,
128    /// Enable bracket orders (entry with TP/SL).
129    #[builder(default = false)]
130    pub enable_brackets: bool,
131    /// Submit limit buy and sell as an order list instead of individual orders.
132    #[builder(default = false)]
133    pub batch_submit_limit_pair: bool,
134    /// Entry order type for bracket orders.
135    #[builder(default = OrderType::Limit)]
136    pub bracket_entry_order_type: OrderType,
137    /// Offset in ticks for bracket TP/SL from entry price.
138    #[builder(default = 500)]
139    pub bracket_offset_ticks: u64,
140    /// Modify limit orders to maintain TOB offset.
141    #[builder(default = false)]
142    pub modify_orders_to_maintain_tob_offset: bool,
143    /// Modify stop orders to maintain offset.
144    #[builder(default = false)]
145    pub modify_stop_orders_to_maintain_offset: bool,
146    /// Cancel and replace limit orders to maintain TOB offset.
147    #[builder(default = false)]
148    pub cancel_replace_orders_to_maintain_tob_offset: bool,
149    /// Cancel and replace stop orders to maintain offset.
150    #[builder(default = false)]
151    pub cancel_replace_stop_orders_to_maintain_offset: bool,
152    /// Trigger one modify or cancel-replace when each enabled limit side is first accepted.
153    /// Combine with exactly one limit-order maintenance mode (TC-E30 to TC-E33).
154    #[builder(default = false)]
155    pub trigger_limit_order_maintenance_once: bool,
156    /// Use post-only for limit orders.
157    #[builder(default = false)]
158    pub use_post_only: bool,
159    /// Place limit orders at marketable prices (cross the spread). Combined
160    /// with `limit_time_in_force = Ioc`/`Fok`, exercises aggressive-fill
161    /// (TC-E13, TC-E15) and passive-no-fill (TC-E14, TC-E16) scenarios when
162    /// inverted with the standard passive offset.
163    #[builder(default = false)]
164    pub limit_aggressive: bool,
165    /// Use quote quantity for orders.
166    #[builder(default = false)]
167    pub use_quote_quantity: bool,
168    /// Emulation trigger type for orders.
169    pub emulation_trigger: Option<TriggerType>,
170    /// Cancel all orders on stop.
171    #[builder(default = true)]
172    pub cancel_orders_on_stop: bool,
173    /// Close all positions on stop.
174    #[builder(default = true)]
175    pub close_positions_on_stop: bool,
176    /// Truncate close-on-stop quantities to this decimal precision.
177    pub close_positions_qty_precision: Option<u8>,
178    /// Time in force for closing positions (None defaults to GTC).
179    pub close_positions_time_in_force: Option<TimeInForce>,
180    /// Use `reduce_only` when closing positions.
181    #[builder(default = true)]
182    pub reduce_only_on_stop: bool,
183    /// Use individual cancel commands instead of `cancel_all`.
184    #[builder(default = false)]
185    pub use_individual_cancels_on_stop: bool,
186    /// Use batch cancel command when stopping.
187    #[builder(default = false)]
188    pub use_batch_cancel_on_stop: bool,
189    /// Dry run mode (no order submission).
190    #[builder(default = false)]
191    pub dry_run: bool,
192    /// Log received data.
193    #[builder(default = true)]
194    pub log_data: bool,
195    /// Test post-only rejection by placing orders on wrong side of spread.
196    #[builder(default = false)]
197    pub test_reject_post_only: bool,
198    /// Test reduce-only rejection by setting `reduce_only` on open position order.
199    #[builder(default = false)]
200    pub test_reject_reduce_only: bool,
201    /// Programmatically attempt one strategy-wide modify against the next
202    /// accepted limit order (whichever side acks first) to exercise the
203    /// adapter's modify-rejection path (TC-E36). Independent of
204    /// `modify_orders_to_maintain_tob_offset`, which only fires on price drift.
205    /// Not honored when `batch_submit_limit_pair` is true; combine with
206    /// individual buy/sell maintenance instead.
207    #[builder(default = false)]
208    pub test_modify_rejected: bool,
209    /// Whether unsubscribe is supported on stop.
210    #[builder(default = true)]
211    pub can_unsubscribe: bool,
212    /// Clamp computed prices to the instrument's `[min_price, max_price]` before submit.
213    #[builder(default = false)]
214    pub clamp_to_instrument_price_range: bool,
215}
216
217impl<S: exec_tester_config_builder::IsComplete> ExecTesterConfigBuilder<S> {
218    /// Validates and builds the [`ExecTesterConfig`].
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if any field fails validation (see [`ExecTesterConfig::validate`]).
223    pub fn build(self) -> ConfigResult<ExecTesterConfig> {
224        let config = self.build_inner();
225        config.validate()?;
226        Ok(config)
227    }
228}
229
230impl ExecTesterConfig {
231    /// Validates the execution tester configuration, collecting every field violation.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if any field fails validation. Multiple validation failures are collected
236    /// into one error.
237    pub fn validate(&self) -> ConfigResult<()> {
238        let mut errors = ConfigErrorCollector::new();
239
240        errors.check(
241            self.book_interval_ms > 0,
242            ConfigError::range("book_interval_ms", "must be positive, was 0"),
243        );
244
245        if let Some(book_depth) = self.book_depth {
246            errors.check(
247                book_depth > 0,
248                ConfigError::range("book_depth", "must be positive, was 0"),
249            );
250        }
251
252        if self.trigger_limit_order_maintenance_once {
253            let modify = self.modify_orders_to_maintain_tob_offset;
254            let cancel_replace = self.cancel_replace_orders_to_maintain_tob_offset;
255
256            errors.check(
257                modify || cancel_replace,
258                ConfigError::required_one_of([
259                    "modify_orders_to_maintain_tob_offset",
260                    "cancel_replace_orders_to_maintain_tob_offset",
261                ]),
262            );
263            errors.check(
264                !(modify && cancel_replace),
265                ConfigError::mutually_exclusive_fields([
266                    "modify_orders_to_maintain_tob_offset",
267                    "cancel_replace_orders_to_maintain_tob_offset",
268                ]),
269            );
270            errors.check(
271                self.enable_limit_buys || self.enable_limit_sells,
272                ConfigError::dependency(
273                    "trigger_limit_order_maintenance_once",
274                    "enable_limit_buys or enable_limit_sells",
275                    "at least one limit side must be enabled",
276                ),
277            );
278            errors.check(
279                !(self.batch_submit_limit_pair
280                    && self.enable_limit_buys
281                    && self.enable_limit_sells),
282                ConfigError::mutually_exclusive_fields([
283                    "trigger_limit_order_maintenance_once",
284                    "batch_submit_limit_pair",
285                ]),
286            );
287            errors.check(
288                !self.enable_brackets,
289                ConfigError::mutually_exclusive_fields([
290                    "trigger_limit_order_maintenance_once",
291                    "enable_brackets",
292                ]),
293            );
294            errors.check(
295                !self.test_modify_rejected,
296                ConfigError::mutually_exclusive_fields([
297                    "trigger_limit_order_maintenance_once",
298                    "test_modify_rejected",
299                ]),
300            );
301        }
302
303        errors.into_result()
304    }
305
306    /// Creates a new [`ExecTesterConfig`] with minimal settings.
307    #[must_use]
308    pub fn new(
309        strategy_id: StrategyId,
310        instrument_id: InstrumentId,
311        client_id: ClientId,
312        order_qty: Quantity,
313    ) -> Self {
314        Self {
315            base: StrategyConfig {
316                strategy_id: Some(strategy_id),
317                order_id_tag: None,
318                ..Default::default()
319            },
320            instrument_id,
321            order_qty,
322            order_display_qty: None,
323            order_expire_time_delta_mins: None,
324            order_params: None,
325            client_id: Some(client_id),
326            subscribe_quotes: true,
327            subscribe_trades: true,
328            subscribe_book: false,
329            book_type: BookType::L2_MBP,
330            book_depth: None,
331            book_interval_ms: 1000,
332            book_levels_to_print: 10,
333            open_position_on_start_qty: None,
334            open_position_on_first_quote: false,
335            open_position_time_in_force: TimeInForce::Gtc,
336            enable_limit_buys: true,
337            enable_limit_sells: true,
338            enable_stop_buys: false,
339            enable_stop_sells: false,
340            tob_offset_ticks: 500,
341            limit_time_in_force: None,
342            stop_order_type: OrderType::StopMarket,
343            stop_offset_ticks: 100,
344            stop_limit_offset_ticks: None,
345            stop_trigger_type: TriggerType::Default,
346            stop_time_in_force: None,
347            trailing_offset: None,
348            trailing_offset_type: TrailingOffsetType::BasisPoints,
349            enable_brackets: false,
350            batch_submit_limit_pair: false,
351            bracket_entry_order_type: OrderType::Limit,
352            bracket_offset_ticks: 500,
353            modify_orders_to_maintain_tob_offset: false,
354            modify_stop_orders_to_maintain_offset: false,
355            cancel_replace_orders_to_maintain_tob_offset: false,
356            cancel_replace_stop_orders_to_maintain_offset: false,
357            trigger_limit_order_maintenance_once: false,
358            use_post_only: false,
359            limit_aggressive: false,
360            use_quote_quantity: false,
361            emulation_trigger: None,
362            cancel_orders_on_stop: true,
363            close_positions_on_stop: true,
364            close_positions_qty_precision: None,
365            close_positions_time_in_force: None,
366            reduce_only_on_stop: true,
367            use_individual_cancels_on_stop: false,
368            use_batch_cancel_on_stop: false,
369            dry_run: false,
370            log_data: true,
371            test_reject_post_only: false,
372            test_reject_reduce_only: false,
373            test_modify_rejected: false,
374            can_unsubscribe: true,
375            clamp_to_instrument_price_range: false,
376        }
377    }
378}
379
380impl Default for ExecTesterConfig {
381    fn default() -> Self {
382        Self::builder()
383            .build()
384            .expect("default ExecTesterConfig should be valid")
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use rstest::rstest;
391
392    use super::*;
393
394    #[rstest]
395    fn test_default_config_is_valid() {
396        assert!(ExecTesterConfig::builder().build().is_ok());
397    }
398
399    #[rstest]
400    fn test_zero_book_interval_ms_rejected() {
401        let result = ExecTesterConfig::builder().book_interval_ms(0).build();
402        assert!(
403            matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
404        );
405    }
406
407    #[rstest]
408    fn test_zero_book_depth_rejected() {
409        let result = ExecTesterConfig::builder().book_depth(0).build();
410        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
411    }
412}