Skip to main content

nautilus_trading/strategy/
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 std::collections::HashMap;
17
18use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
19use nautilus_core::serialization::{default_false, default_true};
20use nautilus_model::{
21    enums::{OmsType, TimeInForce},
22    identifiers::{InstrumentId, StrategyId, check_order_id_tag},
23};
24use serde::{Deserialize, Serialize};
25
26// Upper bound for `market_exit_interval_ms` so the nanosecond conversion on the market exit
27// timer path (`interval_ms * 1_000_000`) cannot overflow a `u64`.
28const MAX_MARKET_EXIT_INTERVAL_MS: u64 = u64::MAX / 1_000_000;
29
30/// The base model for all trading strategy configurations.
31#[cfg_attr(
32    feature = "python",
33    expect(
34        clippy::unsafe_derive_deserialize,
35        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
36    )
37)]
38#[derive(Clone, Debug, Deserialize, Serialize, bon::Builder)]
39#[builder(finish_fn(name = build_inner, vis = ""))]
40#[serde(deny_unknown_fields)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.trading", subclass, from_py_object)
44)]
45#[cfg_attr(
46    feature = "python",
47    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.trading")
48)]
49pub struct StrategyConfig {
50    /// The unique ID for the strategy. Will become the strategy ID if not None.
51    pub strategy_id: Option<StrategyId>,
52    /// The unique order ID tag for the strategy. Must be unique
53    /// amongst all running strategies for a particular trader ID, and cannot contain the '-'
54    /// strategy ID separator.
55    pub order_id_tag: Option<String>,
56    /// If UUID4's should be used for client order ID values.
57    #[serde(default = "default_false")]
58    #[builder(default)]
59    pub use_uuid_client_order_ids: bool,
60    /// If hyphens should be used in generated client order ID values.
61    #[serde(default = "default_true")]
62    #[builder(default = true)]
63    pub use_hyphens_in_client_order_ids: bool,
64    /// The order management system type for the strategy. This will determine
65    /// how the `ExecutionEngine` handles position IDs.
66    pub oms_type: Option<OmsType>,
67    /// The external order claim instrument IDs.
68    /// External orders, fills, and materialized reconciliation activity for matching instrument IDs
69    /// will be associated with the strategy.
70    pub external_order_claims: Option<Vec<InstrumentId>>,
71    /// If OTO, OCO, and OUO **open** contingent orders should be managed automatically by the strategy.
72    /// Any emulated orders which are active local will be managed by the `OrderEmulator` instead.
73    #[serde(default = "default_false")]
74    #[builder(default)]
75    pub manage_contingent_orders: bool,
76    /// If all order GTD time in force expirations should be managed by the strategy.
77    /// If True, then will ensure open orders have their GTD timers re-activated on start.
78    #[serde(default = "default_false")]
79    #[builder(default)]
80    pub manage_gtd_expiry: bool,
81    /// If the strategy should automatically perform a market exit when stopped.
82    /// If true, calling `stop()` first cancels all orders and closes all positions
83    /// before the strategy transitions to the `STOPPED` state.
84    #[serde(default = "default_false")]
85    #[builder(default)]
86    pub manage_stop: bool,
87    /// The interval in milliseconds to check for in-flight orders and open positions
88    /// during a market exit.
89    #[serde(default = "default_market_exit_interval_ms")]
90    #[builder(default = 100)]
91    pub market_exit_interval_ms: u64,
92    /// The maximum number of attempts to wait for orders and positions to close
93    /// during a market exit before completing. Defaults to 100 attempts
94    /// (10 seconds at 100ms intervals).
95    #[serde(default = "default_market_exit_max_attempts")]
96    #[builder(default = 100)]
97    pub market_exit_max_attempts: u64,
98    /// The time in force for closing market orders during a market exit.
99    #[serde(default = "default_market_exit_time_in_force")]
100    #[builder(default = TimeInForce::Gtc)]
101    pub market_exit_time_in_force: TimeInForce,
102    /// If closing market orders during a market exit should be reduce only.
103    #[serde(default = "default_true")]
104    #[builder(default = true)]
105    pub market_exit_reduce_only: bool,
106    /// If events should be logged by the strategy.
107    /// If False, then only warning events and above are logged.
108    #[serde(default = "default_true")]
109    #[builder(default = true)]
110    pub log_events: bool,
111    /// If commands should be logged by the strategy.
112    #[serde(default = "default_true")]
113    #[builder(default = true)]
114    pub log_commands: bool,
115    /// If order rejected events where `due_post_only` is True should be logged as warnings.
116    #[serde(default = "default_true")]
117    #[builder(default = true)]
118    pub log_rejected_due_post_only_as_warning: bool,
119}
120
121const fn default_market_exit_interval_ms() -> u64 {
122    100
123}
124
125const fn default_market_exit_max_attempts() -> u64 {
126    100
127}
128
129const fn default_market_exit_time_in_force() -> TimeInForce {
130    TimeInForce::Gtc
131}
132
133impl<S: strategy_config_builder::IsComplete> StrategyConfigBuilder<S> {
134    /// Validates and builds the [`StrategyConfig`].
135    ///
136    /// # Errors
137    ///
138    /// Returns a [`ConfigError`] if any field fails validation
139    /// (see [`StrategyConfig::validate`]).
140    pub fn build(self) -> ConfigResult<StrategyConfig> {
141        let config = self.build_inner();
142        config.validate()?;
143        Ok(config)
144    }
145}
146
147impl StrategyConfig {
148    /// Validates the strategy configuration, collecting every field violation.
149    ///
150    /// # Errors
151    ///
152    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
153    /// invalid) if any field fails validation.
154    pub fn validate(&self) -> ConfigResult<()> {
155        let mut errors = ConfigErrorCollector::new();
156
157        if let Some(order_id_tag) = &self.order_id_tag
158            && let Err(e) = check_order_id_tag(order_id_tag)
159        {
160            errors.push(ConfigError::invalid_value("order_id_tag", e.to_string()));
161        }
162
163        let interval_ms = self.market_exit_interval_ms;
164        errors.check(
165            interval_ms > 0,
166            ConfigError::range(
167                "market_exit_interval_ms",
168                format!("must be a positive number of milliseconds, was {interval_ms}"),
169            ),
170        );
171        errors.check(
172            interval_ms <= MAX_MARKET_EXIT_INTERVAL_MS,
173            ConfigError::range(
174                "market_exit_interval_ms",
175                format!(
176                    "must be at most {MAX_MARKET_EXIT_INTERVAL_MS} milliseconds to convert to \
177                    nanoseconds without overflow, was {interval_ms}"
178                ),
179            ),
180        );
181
182        let max_attempts = self.market_exit_max_attempts;
183        errors.check(
184            max_attempts > 0,
185            ConfigError::range(
186                "market_exit_max_attempts",
187                format!("must be a positive number of attempts, was {max_attempts}"),
188            ),
189        );
190
191        let time_in_force = self.market_exit_time_in_force;
192        errors.check(
193            time_in_force != TimeInForce::Gtd,
194            ConfigError::unsupported_value(
195                "market_exit_time_in_force",
196                format!("{time_in_force} is not supported for market orders"),
197            ),
198        );
199
200        errors.into_result()
201    }
202}
203
204/// Configuration for creating strategies from importable paths.
205#[cfg_attr(
206    feature = "python",
207    expect(
208        clippy::unsafe_derive_deserialize,
209        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
210    )
211)]
212#[derive(Debug, Clone, Deserialize, Serialize)]
213#[serde(deny_unknown_fields)]
214#[cfg_attr(
215    feature = "python",
216    pyo3::pyclass(module = "nautilus_trader.trading", from_py_object)
217)]
218#[cfg_attr(
219    feature = "python",
220    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.trading")
221)]
222pub struct ImportableStrategyConfig {
223    /// The fully qualified name of the Strategy class.
224    pub strategy_path: String,
225    /// The fully qualified name of the Strategy config class.
226    pub config_path: String,
227    /// The strategy configuration as a dictionary.
228    pub config: HashMap<String, serde_json::Value>,
229}
230
231impl Default for StrategyConfig {
232    fn default() -> Self {
233        Self::builder()
234            .build()
235            .expect("default `StrategyConfig` should be valid")
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use rstest::rstest;
242    use strum::IntoEnumIterator;
243
244    use super::*;
245
246    #[rstest]
247    fn test_default_config_is_valid() {
248        assert!(StrategyConfig::builder().build().is_ok());
249    }
250
251    #[rstest]
252    fn test_zero_market_exit_interval_rejected() {
253        let result = StrategyConfig::builder().market_exit_interval_ms(0).build();
254        assert!(
255            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_interval_ms")
256        );
257    }
258
259    #[rstest]
260    fn test_market_exit_interval_above_nanosecond_bound_rejected() {
261        let result = StrategyConfig::builder()
262            .market_exit_interval_ms(18_446_744_073_710)
263            .build();
264        let Err(ConfigError::Range { field, reason }) = result else {
265            panic!("expected ConfigError::Range");
266        };
267        assert_eq!(field, "market_exit_interval_ms");
268        assert_eq!(
269            reason,
270            "must be at most 18446744073709 milliseconds to convert to nanoseconds without overflow, \
271            was 18446744073710"
272        );
273    }
274
275    #[rstest]
276    fn test_market_exit_interval_at_nanosecond_bound_accepted() {
277        let config = StrategyConfig::builder()
278            .market_exit_interval_ms(18_446_744_073_709)
279            .build();
280
281        assert!(config.is_ok());
282    }
283
284    #[rstest]
285    fn test_zero_market_exit_max_attempts_rejected() {
286        let result = StrategyConfig::builder()
287            .market_exit_max_attempts(0)
288            .build();
289        assert!(
290            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_max_attempts")
291        );
292    }
293
294    #[rstest]
295    #[case("001")]
296    #[case("ABC")]
297    fn test_order_id_tag_without_separator_accepted(#[case] order_id_tag: &str) {
298        let config = StrategyConfig::builder()
299            .order_id_tag(order_id_tag.to_string())
300            .build()
301            .unwrap();
302
303        assert_eq!(config.order_id_tag.as_deref(), Some(order_id_tag));
304    }
305
306    #[rstest]
307    #[case("A-B")]
308    #[case("XNAS-T01")]
309    fn test_order_id_tag_with_separator_rejected(#[case] order_id_tag: &str) {
310        let result = StrategyConfig::builder()
311            .order_id_tag(order_id_tag.to_string())
312            .build();
313
314        let ConfigError::InvalidValue { field, reason } = result.unwrap_err() else {
315            panic!("expected ConfigError::InvalidValue");
316        };
317        assert_eq!(field, "order_id_tag");
318        assert_eq!(
319            reason,
320            format!(
321                "`order_id_tag` cannot contain the '-' strategy ID separator, was '{order_id_tag}'"
322            )
323        );
324    }
325
326    #[rstest]
327    fn test_gtd_market_exit_time_in_force_rejected() {
328        let result = StrategyConfig::builder()
329            .market_exit_time_in_force(TimeInForce::Gtd)
330            .build();
331        let Err(ConfigError::UnsupportedValue { field, reason }) = result else {
332            panic!("expected ConfigError::UnsupportedValue");
333        };
334        assert_eq!(field, "market_exit_time_in_force");
335        assert_eq!(reason, "GTD is not supported for market orders");
336    }
337
338    // Iterates the enum rather than listing cases, so a variant added later is covered
339    // without editing this test: the invariant is that every time in force except GTD
340    // is accepted, mirroring `MarketOrder::new_checked`.
341    #[rstest]
342    fn test_non_gtd_market_exit_time_in_force_accepted() {
343        for time_in_force in TimeInForce::iter().filter(|t| *t != TimeInForce::Gtd) {
344            assert!(
345                StrategyConfig::builder()
346                    .market_exit_time_in_force(time_in_force)
347                    .build()
348                    .is_ok(),
349                "{time_in_force} should be accepted"
350            );
351        }
352    }
353
354    #[rstest]
355    fn test_multiple_violations_collected() {
356        let result = StrategyConfig::builder()
357            .market_exit_interval_ms(0)
358            .market_exit_max_attempts(0)
359            .market_exit_time_in_force(TimeInForce::Gtd)
360            .build();
361        let ConfigError::Multiple { errors } = result.unwrap_err() else {
362            panic!("expected ConfigError::Multiple");
363        };
364        // Asserted by index, not membership: the collector preserves insertion order, so
365        // checking position also pins that the new check runs after the two numeric ones.
366        assert_eq!(errors.len(), 3);
367        assert!(matches!(
368            &errors[0],
369            ConfigError::Range { field, .. } if field == "market_exit_interval_ms"
370        ));
371        assert!(matches!(
372            &errors[1],
373            ConfigError::Range { field, .. } if field == "market_exit_max_attempts"
374        ));
375        assert!(matches!(
376            &errors[2],
377            ConfigError::UnsupportedValue { field, .. } if field == "market_exit_time_in_force"
378        ));
379    }
380
381    #[rstest]
382    fn test_strategy_config_default() {
383        let config = StrategyConfig::default();
384
385        assert!(config.strategy_id.is_none());
386        assert!(config.order_id_tag.is_none());
387        assert!(!config.use_uuid_client_order_ids);
388        assert!(config.use_hyphens_in_client_order_ids);
389        assert!(config.oms_type.is_none());
390        assert!(config.external_order_claims.is_none());
391        assert!(!config.manage_contingent_orders);
392        assert!(!config.manage_gtd_expiry);
393        assert!(!config.manage_stop);
394        assert_eq!(config.market_exit_interval_ms, 100);
395        assert_eq!(config.market_exit_max_attempts, 100);
396        assert_eq!(config.market_exit_time_in_force, TimeInForce::Gtc);
397        assert!(config.market_exit_reduce_only);
398        assert!(config.log_events);
399        assert!(config.log_commands);
400        assert!(config.log_rejected_due_post_only_as_warning);
401    }
402
403    #[rstest]
404    fn test_strategy_config_with_strategy_id() {
405        let strategy_id = StrategyId::from("TEST-001");
406        let config = StrategyConfig {
407            strategy_id: Some(strategy_id),
408            ..Default::default()
409        };
410
411        assert_eq!(config.strategy_id, Some(strategy_id));
412    }
413
414    #[rstest]
415    fn test_strategy_config_serialization() {
416        let config = StrategyConfig {
417            strategy_id: Some(StrategyId::from("TEST-001")),
418            order_id_tag: Some("TAG1".to_string()),
419            use_uuid_client_order_ids: true,
420            ..Default::default()
421        };
422
423        let json = serde_json::to_string(&config).unwrap();
424        let deserialized: StrategyConfig = serde_json::from_str(&json).unwrap();
425
426        assert_eq!(config.strategy_id, deserialized.strategy_id);
427        assert_eq!(config.order_id_tag, deserialized.order_id_tag);
428        assert_eq!(
429            config.use_uuid_client_order_ids,
430            deserialized.use_uuid_client_order_ids
431        );
432    }
433}