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.core.nautilus_pyo3.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    /// Use post-only for limit orders.
153    #[builder(default = false)]
154    pub use_post_only: bool,
155    /// Place limit orders at marketable prices (cross the spread). Combined
156    /// with `limit_time_in_force = Ioc`/`Fok`, exercises aggressive-fill
157    /// (TC-E13, TC-E15) and passive-no-fill (TC-E14, TC-E16) scenarios when
158    /// inverted with the standard passive offset.
159    #[builder(default = false)]
160    pub limit_aggressive: bool,
161    /// Use quote quantity for orders.
162    #[builder(default = false)]
163    pub use_quote_quantity: bool,
164    /// Emulation trigger type for orders.
165    pub emulation_trigger: Option<TriggerType>,
166    /// Cancel all orders on stop.
167    #[builder(default = true)]
168    pub cancel_orders_on_stop: bool,
169    /// Close all positions on stop.
170    #[builder(default = true)]
171    pub close_positions_on_stop: bool,
172    /// Time in force for closing positions (None defaults to GTC).
173    pub close_positions_time_in_force: Option<TimeInForce>,
174    /// Use `reduce_only` when closing positions.
175    #[builder(default = true)]
176    pub reduce_only_on_stop: bool,
177    /// Use individual cancel commands instead of `cancel_all`.
178    #[builder(default = false)]
179    pub use_individual_cancels_on_stop: bool,
180    /// Use batch cancel command when stopping.
181    #[builder(default = false)]
182    pub use_batch_cancel_on_stop: bool,
183    /// Dry run mode (no order submission).
184    #[builder(default = false)]
185    pub dry_run: bool,
186    /// Log received data.
187    #[builder(default = true)]
188    pub log_data: bool,
189    /// Test post-only rejection by placing orders on wrong side of spread.
190    #[builder(default = false)]
191    pub test_reject_post_only: bool,
192    /// Test reduce-only rejection by setting `reduce_only` on open position order.
193    #[builder(default = false)]
194    pub test_reject_reduce_only: bool,
195    /// Programmatically attempt one strategy-wide modify against the next
196    /// accepted limit order (whichever side acks first) to exercise the
197    /// adapter's modify-rejection path (TC-E36). Independent of
198    /// `modify_orders_to_maintain_tob_offset`, which only fires on price drift.
199    /// Not honored when `batch_submit_limit_pair` is true; combine with
200    /// individual buy/sell maintenance instead.
201    #[builder(default = false)]
202    pub test_modify_rejected: bool,
203    /// Whether unsubscribe is supported on stop.
204    #[builder(default = true)]
205    pub can_unsubscribe: bool,
206    /// Clamp computed prices to the instrument's `[min_price, max_price]` before submit.
207    #[builder(default = false)]
208    pub clamp_to_instrument_price_range: bool,
209}
210
211impl<S: exec_tester_config_builder::IsComplete> ExecTesterConfigBuilder<S> {
212    /// Validates and builds the [`ExecTesterConfig`].
213    ///
214    /// # Errors
215    ///
216    /// Returns a [`ConfigError`] if any field fails validation
217    /// (see [`ExecTesterConfig::validate`]).
218    pub fn build(self) -> ConfigResult<ExecTesterConfig> {
219        let config = self.build_inner();
220        config.validate()?;
221        Ok(config)
222    }
223}
224
225impl ExecTesterConfig {
226    /// Validates the execution tester configuration, collecting every field violation.
227    ///
228    /// # Errors
229    ///
230    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
231    /// invalid) if any field fails validation.
232    pub fn validate(&self) -> ConfigResult<()> {
233        let mut errors = ConfigErrorCollector::new();
234
235        errors.check(
236            self.book_interval_ms > 0,
237            ConfigError::range("book_interval_ms", "must be positive, was 0"),
238        );
239
240        if let Some(book_depth) = self.book_depth {
241            errors.check(
242                book_depth > 0,
243                ConfigError::range("book_depth", "must be positive, was 0"),
244            );
245        }
246
247        errors.into_result()
248    }
249
250    /// Creates a new [`ExecTesterConfig`] with minimal settings.
251    #[must_use]
252    pub fn new(
253        strategy_id: StrategyId,
254        instrument_id: InstrumentId,
255        client_id: ClientId,
256        order_qty: Quantity,
257    ) -> Self {
258        Self {
259            base: StrategyConfig {
260                strategy_id: Some(strategy_id),
261                order_id_tag: None,
262                ..Default::default()
263            },
264            instrument_id,
265            order_qty,
266            order_display_qty: None,
267            order_expire_time_delta_mins: None,
268            order_params: None,
269            client_id: Some(client_id),
270            subscribe_quotes: true,
271            subscribe_trades: true,
272            subscribe_book: false,
273            book_type: BookType::L2_MBP,
274            book_depth: None,
275            book_interval_ms: 1000,
276            book_levels_to_print: 10,
277            open_position_on_start_qty: None,
278            open_position_on_first_quote: false,
279            open_position_time_in_force: TimeInForce::Gtc,
280            enable_limit_buys: true,
281            enable_limit_sells: true,
282            enable_stop_buys: false,
283            enable_stop_sells: false,
284            tob_offset_ticks: 500,
285            limit_time_in_force: None,
286            stop_order_type: OrderType::StopMarket,
287            stop_offset_ticks: 100,
288            stop_limit_offset_ticks: None,
289            stop_trigger_type: TriggerType::Default,
290            stop_time_in_force: None,
291            trailing_offset: None,
292            trailing_offset_type: TrailingOffsetType::BasisPoints,
293            enable_brackets: false,
294            batch_submit_limit_pair: false,
295            bracket_entry_order_type: OrderType::Limit,
296            bracket_offset_ticks: 500,
297            modify_orders_to_maintain_tob_offset: false,
298            modify_stop_orders_to_maintain_offset: false,
299            cancel_replace_orders_to_maintain_tob_offset: false,
300            cancel_replace_stop_orders_to_maintain_offset: false,
301            use_post_only: false,
302            limit_aggressive: false,
303            use_quote_quantity: false,
304            emulation_trigger: None,
305            cancel_orders_on_stop: true,
306            close_positions_on_stop: true,
307            close_positions_time_in_force: None,
308            reduce_only_on_stop: true,
309            use_individual_cancels_on_stop: false,
310            use_batch_cancel_on_stop: false,
311            dry_run: false,
312            log_data: true,
313            test_reject_post_only: false,
314            test_reject_reduce_only: false,
315            test_modify_rejected: false,
316            can_unsubscribe: true,
317            clamp_to_instrument_price_range: false,
318        }
319    }
320}
321
322impl Default for ExecTesterConfig {
323    fn default() -> Self {
324        Self::builder()
325            .build()
326            .expect("default ExecTesterConfig should be valid")
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use rstest::rstest;
333
334    use super::*;
335
336    #[rstest]
337    fn test_default_config_is_valid() {
338        assert!(ExecTesterConfig::builder().build().is_ok());
339    }
340
341    #[rstest]
342    fn test_zero_book_interval_ms_rejected() {
343        let result = ExecTesterConfig::builder().book_interval_ms(0).build();
344        assert!(
345            matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
346        );
347    }
348
349    #[rstest]
350    fn test_zero_book_depth_rejected() {
351        let result = ExecTesterConfig::builder().book_depth(0).build();
352        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
353    }
354}