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