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},
23};
24use serde::{Deserialize, Serialize};
25
26/// The base model for all trading strategy configurations.
27#[cfg_attr(
28    feature = "python",
29    expect(
30        clippy::unsafe_derive_deserialize,
31        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
32    )
33)]
34#[derive(Clone, Debug, Deserialize, Serialize, bon::Builder)]
35#[builder(finish_fn(name = build_inner, vis = ""))]
36#[serde(deny_unknown_fields)]
37#[cfg_attr(
38    feature = "python",
39    pyo3::pyclass(
40        module = "nautilus_trader.core.nautilus_pyo3.trading",
41        subclass,
42        from_py_object
43    )
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.
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    /// The external order claim instrument IDs.
67    /// External orders, fills, and materialized reconciliation activity for matching instrument IDs
68    /// will be associated with the strategy.
69    pub external_order_claims: Option<Vec<InstrumentId>>,
70    /// If OTO, OCO, and OUO **open** contingent orders should be managed automatically by the strategy.
71    /// Any emulated orders which are active local will be managed by the `OrderEmulator` instead.
72    #[serde(default = "default_false")]
73    #[builder(default)]
74    pub manage_contingent_orders: bool,
75    /// If all order GTD time in force expirations should be managed by the strategy.
76    /// If True, then will ensure open orders have their GTD timers re-activated on start.
77    #[serde(default = "default_false")]
78    #[builder(default)]
79    pub manage_gtd_expiry: bool,
80    /// If the strategy should automatically perform a market exit when stopped.
81    /// If true, calling `stop()` first cancels all orders and closes all positions
82    /// before the strategy transitions to the `STOPPED` state.
83    #[serde(default = "default_false")]
84    #[builder(default)]
85    pub manage_stop: bool,
86    /// The interval in milliseconds to check for in-flight orders and open positions
87    /// during a market exit.
88    #[serde(default = "default_market_exit_interval_ms")]
89    #[builder(default = 100)]
90    pub market_exit_interval_ms: u64,
91    /// The maximum number of attempts to wait for orders and positions to close
92    /// during a market exit before completing. Defaults to 100 attempts
93    /// (10 seconds at 100ms intervals).
94    #[serde(default = "default_market_exit_max_attempts")]
95    #[builder(default = 100)]
96    pub market_exit_max_attempts: u64,
97    /// The time in force for closing market orders during a market exit.
98    #[serde(default = "default_market_exit_time_in_force")]
99    #[builder(default = TimeInForce::Gtc)]
100    pub market_exit_time_in_force: TimeInForce,
101    /// If closing market orders during a market exit should be reduce only.
102    #[serde(default = "default_true")]
103    #[builder(default = true)]
104    pub market_exit_reduce_only: bool,
105    /// If events should be logged by the strategy.
106    /// If False, then only warning events and above are logged.
107    #[serde(default = "default_true")]
108    #[builder(default = true)]
109    pub log_events: bool,
110    /// If commands should be logged by the strategy.
111    #[serde(default = "default_true")]
112    #[builder(default = true)]
113    pub log_commands: bool,
114    /// If order rejected events where `due_post_only` is True should be logged as warnings.
115    #[serde(default = "default_true")]
116    #[builder(default = true)]
117    pub log_rejected_due_post_only_as_warning: bool,
118}
119
120const fn default_market_exit_interval_ms() -> u64 {
121    100
122}
123
124const fn default_market_exit_max_attempts() -> u64 {
125    100
126}
127
128const fn default_market_exit_time_in_force() -> TimeInForce {
129    TimeInForce::Gtc
130}
131
132impl<S: strategy_config_builder::IsComplete> StrategyConfigBuilder<S> {
133    /// Validates and builds the [`StrategyConfig`].
134    ///
135    /// # Errors
136    ///
137    /// Returns a [`ConfigError`] if any field fails validation
138    /// (see [`StrategyConfig::validate`]).
139    pub fn build(self) -> ConfigResult<StrategyConfig> {
140        let config = self.build_inner();
141        config.validate()?;
142        Ok(config)
143    }
144}
145
146impl StrategyConfig {
147    /// Validates the strategy configuration, collecting every field violation.
148    ///
149    /// # Errors
150    ///
151    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
152    /// invalid) if any field fails validation.
153    pub fn validate(&self) -> ConfigResult<()> {
154        let mut errors = ConfigErrorCollector::new();
155
156        let interval_ms = self.market_exit_interval_ms;
157        errors.check(
158            interval_ms > 0,
159            ConfigError::range(
160                "market_exit_interval_ms",
161                format!("must be a positive number of milliseconds, was {interval_ms}"),
162            ),
163        );
164
165        let max_attempts = self.market_exit_max_attempts;
166        errors.check(
167            max_attempts > 0,
168            ConfigError::range(
169                "market_exit_max_attempts",
170                format!("must be a positive number of attempts, was {max_attempts}"),
171            ),
172        );
173
174        errors.into_result()
175    }
176}
177
178/// Configuration for creating strategies from importable paths.
179#[cfg_attr(
180    feature = "python",
181    expect(
182        clippy::unsafe_derive_deserialize,
183        reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
184    )
185)]
186#[derive(Debug, Clone, Deserialize, Serialize)]
187#[serde(deny_unknown_fields)]
188#[cfg_attr(
189    feature = "python",
190    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.trading", from_py_object)
191)]
192#[cfg_attr(
193    feature = "python",
194    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.trading")
195)]
196pub struct ImportableStrategyConfig {
197    /// The fully qualified name of the Strategy class.
198    pub strategy_path: String,
199    /// The fully qualified name of the Strategy config class.
200    pub config_path: String,
201    /// The strategy configuration as a dictionary.
202    pub config: HashMap<String, serde_json::Value>,
203}
204
205impl Default for StrategyConfig {
206    fn default() -> Self {
207        Self::builder()
208            .build()
209            .expect("default `StrategyConfig` should be valid")
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use rstest::rstest;
216
217    use super::*;
218
219    #[rstest]
220    fn test_default_config_is_valid() {
221        assert!(StrategyConfig::builder().build().is_ok());
222    }
223
224    #[rstest]
225    fn test_zero_market_exit_interval_rejected() {
226        let result = StrategyConfig::builder().market_exit_interval_ms(0).build();
227        assert!(
228            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_interval_ms")
229        );
230    }
231
232    #[rstest]
233    fn test_zero_market_exit_max_attempts_rejected() {
234        let result = StrategyConfig::builder()
235            .market_exit_max_attempts(0)
236            .build();
237        assert!(
238            matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_max_attempts")
239        );
240    }
241
242    #[rstest]
243    fn test_multiple_violations_collected() {
244        let result = StrategyConfig::builder()
245            .market_exit_interval_ms(0)
246            .market_exit_max_attempts(0)
247            .build();
248        let ConfigError::Multiple { errors } = result.unwrap_err() else {
249            panic!("expected ConfigError::Multiple");
250        };
251        assert_eq!(errors.len(), 2);
252    }
253
254    #[rstest]
255    fn test_strategy_config_default() {
256        let config = StrategyConfig::default();
257
258        assert!(config.strategy_id.is_none());
259        assert!(config.order_id_tag.is_none());
260        assert!(!config.use_uuid_client_order_ids);
261        assert!(config.use_hyphens_in_client_order_ids);
262        assert!(config.oms_type.is_none());
263        assert!(config.external_order_claims.is_none());
264        assert!(!config.manage_contingent_orders);
265        assert!(!config.manage_gtd_expiry);
266        assert!(!config.manage_stop);
267        assert_eq!(config.market_exit_interval_ms, 100);
268        assert_eq!(config.market_exit_max_attempts, 100);
269        assert_eq!(config.market_exit_time_in_force, TimeInForce::Gtc);
270        assert!(config.market_exit_reduce_only);
271        assert!(config.log_events);
272        assert!(config.log_commands);
273        assert!(config.log_rejected_due_post_only_as_warning);
274    }
275
276    #[rstest]
277    fn test_strategy_config_with_strategy_id() {
278        let strategy_id = StrategyId::from("TEST-001");
279        let config = StrategyConfig {
280            strategy_id: Some(strategy_id),
281            ..Default::default()
282        };
283
284        assert_eq!(config.strategy_id, Some(strategy_id));
285    }
286
287    #[rstest]
288    fn test_strategy_config_serialization() {
289        let config = StrategyConfig {
290            strategy_id: Some(StrategyId::from("TEST-001")),
291            order_id_tag: Some("TAG1".to_string()),
292            use_uuid_client_order_ids: true,
293            ..Default::default()
294        };
295
296        let json = serde_json::to_string(&config).unwrap();
297        let deserialized: StrategyConfig = serde_json::from_str(&json).unwrap();
298
299        assert_eq!(config.strategy_id, deserialized.strategy_id);
300        assert_eq!(config.order_id_tag, deserialized.order_id_tag);
301        assert_eq!(
302            config.use_uuid_client_order_ids,
303            deserialized.use_uuid_client_order_ids
304        );
305    }
306}