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 a [`ConfigError`] if any field fails validation
223    /// (see [`ExecTesterConfig::validate`]).
224    pub fn build(self) -> ConfigResult<ExecTesterConfig> {
225        let config = self.build_inner();
226        config.validate()?;
227        Ok(config)
228    }
229}
230
231impl ExecTesterConfig {
232    /// Validates the execution tester configuration, collecting every field violation.
233    ///
234    /// # Errors
235    ///
236    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
237    /// invalid) if any field fails validation.
238    pub fn validate(&self) -> ConfigResult<()> {
239        let mut errors = ConfigErrorCollector::new();
240
241        errors.check(
242            self.book_interval_ms > 0,
243            ConfigError::range("book_interval_ms", "must be positive, was 0"),
244        );
245
246        if let Some(book_depth) = self.book_depth {
247            errors.check(
248                book_depth > 0,
249                ConfigError::range("book_depth", "must be positive, was 0"),
250            );
251        }
252
253        if self.trigger_limit_order_maintenance_once {
254            let modify = self.modify_orders_to_maintain_tob_offset;
255            let cancel_replace = self.cancel_replace_orders_to_maintain_tob_offset;
256
257            errors.check(
258                modify || cancel_replace,
259                ConfigError::required_one_of([
260                    "modify_orders_to_maintain_tob_offset",
261                    "cancel_replace_orders_to_maintain_tob_offset",
262                ]),
263            );
264            errors.check(
265                !(modify && cancel_replace),
266                ConfigError::mutually_exclusive_fields([
267                    "modify_orders_to_maintain_tob_offset",
268                    "cancel_replace_orders_to_maintain_tob_offset",
269                ]),
270            );
271            errors.check(
272                self.enable_limit_buys || self.enable_limit_sells,
273                ConfigError::dependency(
274                    "trigger_limit_order_maintenance_once",
275                    "enable_limit_buys or enable_limit_sells",
276                    "at least one limit side must be enabled",
277                ),
278            );
279            errors.check(
280                !(self.batch_submit_limit_pair
281                    && self.enable_limit_buys
282                    && self.enable_limit_sells),
283                ConfigError::mutually_exclusive_fields([
284                    "trigger_limit_order_maintenance_once",
285                    "batch_submit_limit_pair",
286                ]),
287            );
288            errors.check(
289                !self.enable_brackets,
290                ConfigError::mutually_exclusive_fields([
291                    "trigger_limit_order_maintenance_once",
292                    "enable_brackets",
293                ]),
294            );
295            errors.check(
296                !self.test_modify_rejected,
297                ConfigError::mutually_exclusive_fields([
298                    "trigger_limit_order_maintenance_once",
299                    "test_modify_rejected",
300                ]),
301            );
302        }
303
304        errors.into_result()
305    }
306
307    /// Creates a new [`ExecTesterConfig`] with minimal settings.
308    #[must_use]
309    pub fn new(
310        strategy_id: StrategyId,
311        instrument_id: InstrumentId,
312        client_id: ClientId,
313        order_qty: Quantity,
314    ) -> Self {
315        Self {
316            base: StrategyConfig {
317                strategy_id: Some(strategy_id),
318                order_id_tag: None,
319                ..Default::default()
320            },
321            instrument_id,
322            order_qty,
323            order_display_qty: None,
324            order_expire_time_delta_mins: None,
325            order_params: None,
326            client_id: Some(client_id),
327            subscribe_quotes: true,
328            subscribe_trades: true,
329            subscribe_book: false,
330            book_type: BookType::L2_MBP,
331            book_depth: None,
332            book_interval_ms: 1000,
333            book_levels_to_print: 10,
334            open_position_on_start_qty: None,
335            open_position_on_first_quote: false,
336            open_position_time_in_force: TimeInForce::Gtc,
337            enable_limit_buys: true,
338            enable_limit_sells: true,
339            enable_stop_buys: false,
340            enable_stop_sells: false,
341            tob_offset_ticks: 500,
342            limit_time_in_force: None,
343            stop_order_type: OrderType::StopMarket,
344            stop_offset_ticks: 100,
345            stop_limit_offset_ticks: None,
346            stop_trigger_type: TriggerType::Default,
347            stop_time_in_force: None,
348            trailing_offset: None,
349            trailing_offset_type: TrailingOffsetType::BasisPoints,
350            enable_brackets: false,
351            batch_submit_limit_pair: false,
352            bracket_entry_order_type: OrderType::Limit,
353            bracket_offset_ticks: 500,
354            modify_orders_to_maintain_tob_offset: false,
355            modify_stop_orders_to_maintain_offset: false,
356            cancel_replace_orders_to_maintain_tob_offset: false,
357            cancel_replace_stop_orders_to_maintain_offset: false,
358            trigger_limit_order_maintenance_once: false,
359            use_post_only: false,
360            limit_aggressive: false,
361            use_quote_quantity: false,
362            emulation_trigger: None,
363            cancel_orders_on_stop: true,
364            close_positions_on_stop: true,
365            close_positions_qty_precision: None,
366            close_positions_time_in_force: None,
367            reduce_only_on_stop: true,
368            use_individual_cancels_on_stop: false,
369            use_batch_cancel_on_stop: false,
370            dry_run: false,
371            log_data: true,
372            test_reject_post_only: false,
373            test_reject_reduce_only: false,
374            test_modify_rejected: false,
375            can_unsubscribe: true,
376            clamp_to_instrument_price_range: false,
377        }
378    }
379}
380
381impl Default for ExecTesterConfig {
382    fn default() -> Self {
383        Self::builder()
384            .build()
385            .expect("default ExecTesterConfig should be valid")
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use rstest::rstest;
392
393    use super::*;
394
395    #[rstest]
396    fn test_default_config_is_valid() {
397        assert!(ExecTesterConfig::builder().build().is_ok());
398    }
399
400    #[rstest]
401    fn test_zero_book_interval_ms_rejected() {
402        let result = ExecTesterConfig::builder().book_interval_ms(0).build();
403        assert!(
404            matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
405        );
406    }
407
408    #[rstest]
409    fn test_zero_book_depth_rejected() {
410        let result = ExecTesterConfig::builder().book_depth(0).build();
411        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
412    }
413}