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 an error if any field fails validation (see [`StrategyConfig::validate`]).
137    pub fn build(self) -> ConfigResult<StrategyConfig> {
138        let config = self.build_inner();
139        config.validate()?;
140        Ok(config)
141    }
142}
143
144impl StrategyConfig {
145    /// Validates the strategy configuration, collecting every field violation.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if any field fails validation. Multiple validation failures are collected
150    /// into one error.
151    pub fn validate(&self) -> ConfigResult<()> {
152        let mut errors = ConfigErrorCollector::new();
153
154        if let Some(order_id_tag) = &self.order_id_tag
155            && let Err(e) = check_order_id_tag(order_id_tag)
156        {
157            errors.push(ConfigError::invalid_value("order_id_tag", e.to_string()));
158        }
159
160        let interval_ms = self.market_exit_interval_ms;
161        errors.check(
162            interval_ms > 0,
163            ConfigError::range(
164                "market_exit_interval_ms",
165                format!("must be a positive number of milliseconds, was {interval_ms}"),
166            ),
167        );
168        errors.check(
169            interval_ms <= MAX_MARKET_EXIT_INTERVAL_MS,
170            ConfigError::range(
171                "market_exit_interval_ms",
172                format!(
173                    "must be at most {MAX_MARKET_EXIT_INTERVAL_MS} milliseconds to convert to \
174                    nanoseconds without overflow, was {interval_ms}"
175                ),
176            ),
177        );
178
179        let max_attempts = self.market_exit_max_attempts;
180        errors.check(
181            max_attempts > 0,
182            ConfigError::range(
183                "market_exit_max_attempts",
184                format!("must be a positive number of attempts, was {max_attempts}"),
185            ),
186        );
187
188        let time_in_force = self.market_exit_time_in_force;
189        errors.check(
190            time_in_force != TimeInForce::Gtd,
191            ConfigError::unsupported_value(
192                "market_exit_time_in_force",
193                format!("{time_in_force} is not supported for market orders"),
194            ),
195        );
196
197        errors.into_result()
198    }
199}
200
201/// Configuration for creating strategies from importable paths.
202#[cfg_attr(
203    feature = "python",
204    expect(
205        clippy::unsafe_derive_deserialize,
206        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
207    )
208)]
209#[derive(Debug, Clone, Deserialize, Serialize)]
210#[serde(deny_unknown_fields)]
211#[cfg_attr(
212    feature = "python",
213    pyo3::pyclass(module = "nautilus_trader.trading", from_py_object)
214)]
215#[cfg_attr(
216    feature = "python",
217    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.trading")
218)]
219pub struct ImportableStrategyConfig {
220    /// The fully qualified name of the Strategy class.
221    pub strategy_path: String,
222    /// The fully qualified name of the Strategy config class.
223    pub config_path: String,
224    /// The strategy configuration as a dictionary.
225    pub config: HashMap<String, serde_json::Value>,
226}
227
228impl Default for StrategyConfig {
229    fn default() -> Self {
230        Self::builder()
231            .build()
232            .expect("default `StrategyConfig` should be valid")
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use rstest::rstest;
239    use strum::IntoEnumIterator;
240
241    use super::*;
242
243    #[rstest]
244    fn test_default_config_is_valid() {
245        assert!(StrategyConfig::builder().build().is_ok());
246    }
247
248    #[rstest]
249    fn test_zero_market_exit_interval_rejected() {
250        let result = StrategyConfig::builder().market_exit_interval_ms(0).build();
251        assert!(
252            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_interval_ms")
253        );
254    }
255
256    #[rstest]
257    fn test_market_exit_interval_above_nanosecond_bound_rejected() {
258        let result = StrategyConfig::builder()
259            .market_exit_interval_ms(18_446_744_073_710)
260            .build();
261        let Err(ConfigError::Range { field, reason }) = result else {
262            panic!("expected ConfigError::Range");
263        };
264        assert_eq!(field, "market_exit_interval_ms");
265        assert_eq!(
266            reason,
267            "must be at most 18446744073709 milliseconds to convert to nanoseconds without overflow, \
268            was 18446744073710"
269        );
270    }
271
272    #[rstest]
273    fn test_market_exit_interval_at_nanosecond_bound_accepted() {
274        let config = StrategyConfig::builder()
275            .market_exit_interval_ms(18_446_744_073_709)
276            .build();
277
278        assert!(config.is_ok());
279    }
280
281    #[rstest]
282    fn test_zero_market_exit_max_attempts_rejected() {
283        let result = StrategyConfig::builder()
284            .market_exit_max_attempts(0)
285            .build();
286        assert!(
287            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_max_attempts")
288        );
289    }
290
291    #[rstest]
292    #[case("001")]
293    #[case("ABC")]
294    fn test_order_id_tag_without_separator_accepted(#[case] order_id_tag: &str) {
295        let config = StrategyConfig::builder()
296            .order_id_tag(order_id_tag.to_string())
297            .build()
298            .unwrap();
299
300        assert_eq!(config.order_id_tag.as_deref(), Some(order_id_tag));
301    }
302
303    #[rstest]
304    #[case("A-B")]
305    #[case("XNAS-T01")]
306    fn test_order_id_tag_with_separator_rejected(#[case] order_id_tag: &str) {
307        let result = StrategyConfig::builder()
308            .order_id_tag(order_id_tag.to_string())
309            .build();
310
311        let ConfigError::InvalidValue { field, reason } = result.unwrap_err() else {
312            panic!("expected ConfigError::InvalidValue");
313        };
314        assert_eq!(field, "order_id_tag");
315        assert_eq!(
316            reason,
317            format!(
318                "`order_id_tag` cannot contain the '-' strategy ID separator, was '{order_id_tag}'"
319            )
320        );
321    }
322
323    #[rstest]
324    fn test_gtd_market_exit_time_in_force_rejected() {
325        let result = StrategyConfig::builder()
326            .market_exit_time_in_force(TimeInForce::Gtd)
327            .build();
328        let Err(ConfigError::UnsupportedValue { field, reason }) = result else {
329            panic!("expected ConfigError::UnsupportedValue");
330        };
331        assert_eq!(field, "market_exit_time_in_force");
332        assert_eq!(reason, "GTD is not supported for market orders");
333    }
334
335    // Iterates the enum rather than listing cases, so a variant added later is covered
336    // without editing this test: the invariant is that every time in force except GTD
337    // is accepted, mirroring `MarketOrder::new_checked`.
338    #[rstest]
339    fn test_non_gtd_market_exit_time_in_force_accepted() {
340        for time_in_force in TimeInForce::iter().filter(|t| *t != TimeInForce::Gtd) {
341            assert!(
342                StrategyConfig::builder()
343                    .market_exit_time_in_force(time_in_force)
344                    .build()
345                    .is_ok(),
346                "{time_in_force} should be accepted"
347            );
348        }
349    }
350
351    #[rstest]
352    fn test_multiple_violations_collected() {
353        let result = StrategyConfig::builder()
354            .market_exit_interval_ms(0)
355            .market_exit_max_attempts(0)
356            .market_exit_time_in_force(TimeInForce::Gtd)
357            .build();
358        let ConfigError::Multiple { errors } = result.unwrap_err() else {
359            panic!("expected ConfigError::Multiple");
360        };
361        // Asserted by index, not membership: the collector preserves insertion order, so
362        // checking position also pins that the new check runs after the two numeric ones.
363        assert_eq!(errors.len(), 3);
364        assert!(matches!(
365            &errors[0],
366            ConfigError::Range { field, .. } if field == "market_exit_interval_ms"
367        ));
368        assert!(matches!(
369            &errors[1],
370            ConfigError::Range { field, .. } if field == "market_exit_max_attempts"
371        ));
372        assert!(matches!(
373            &errors[2],
374            ConfigError::UnsupportedValue { field, .. } if field == "market_exit_time_in_force"
375        ));
376    }
377
378    #[rstest]
379    fn test_strategy_config_default() {
380        let config = StrategyConfig::default();
381
382        assert!(config.strategy_id.is_none());
383        assert!(config.order_id_tag.is_none());
384        assert!(!config.use_uuid_client_order_ids);
385        assert!(config.use_hyphens_in_client_order_ids);
386        assert!(config.oms_type.is_none());
387        assert!(config.external_order_instrument_ids.is_none());
388        assert!(!config.manage_contingent_orders);
389        assert!(!config.manage_gtd_expiry);
390        assert!(!config.manage_stop);
391        assert_eq!(config.market_exit_interval_ms, 100);
392        assert_eq!(config.market_exit_max_attempts, 100);
393        assert_eq!(config.market_exit_time_in_force, TimeInForce::Gtc);
394        assert!(config.market_exit_reduce_only);
395        assert!(config.log_events);
396        assert!(config.log_commands);
397        assert!(config.log_rejected_due_post_only_as_warning);
398    }
399
400    #[rstest]
401    fn test_strategy_config_with_strategy_id() {
402        let strategy_id = StrategyId::from("TEST-001");
403        let config = StrategyConfig {
404            strategy_id: Some(strategy_id),
405            ..Default::default()
406        };
407
408        assert_eq!(config.strategy_id, Some(strategy_id));
409    }
410
411    #[rstest]
412    fn test_strategy_config_serialization() {
413        let config = StrategyConfig {
414            strategy_id: Some(StrategyId::from("TEST-001")),
415            order_id_tag: Some("TAG1".to_string()),
416            use_uuid_client_order_ids: true,
417            external_order_instrument_ids: Some(vec![InstrumentId::from("AUDUSD.SIM")]),
418            ..Default::default()
419        };
420
421        let json = serde_json::to_string(&config).unwrap();
422        let deserialized: StrategyConfig = serde_json::from_str(&json).unwrap();
423
424        assert_eq!(config.strategy_id, deserialized.strategy_id);
425        assert_eq!(config.order_id_tag, deserialized.order_id_tag);
426        assert_eq!(
427            config.use_uuid_client_order_ids,
428            deserialized.use_uuid_client_order_ids
429        );
430        assert_eq!(
431            config.external_order_instrument_ids,
432            deserialized.external_order_instrument_ids
433        );
434    }
435}