nautilus_trading/strategy/
config.rs1use 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#[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 pub strategy_id: Option<StrategyId>,
52 pub order_id_tag: Option<String>,
55 #[serde(default = "default_false")]
57 #[builder(default)]
58 pub use_uuid_client_order_ids: bool,
59 #[serde(default = "default_true")]
61 #[builder(default = true)]
62 pub use_hyphens_in_client_order_ids: bool,
63 pub oms_type: Option<OmsType>,
66 pub external_order_claims: Option<Vec<InstrumentId>>,
70 #[serde(default = "default_false")]
73 #[builder(default)]
74 pub manage_contingent_orders: bool,
75 #[serde(default = "default_false")]
78 #[builder(default)]
79 pub manage_gtd_expiry: bool,
80 #[serde(default = "default_false")]
84 #[builder(default)]
85 pub manage_stop: bool,
86 #[serde(default = "default_market_exit_interval_ms")]
89 #[builder(default = 100)]
90 pub market_exit_interval_ms: u64,
91 #[serde(default = "default_market_exit_max_attempts")]
95 #[builder(default = 100)]
96 pub market_exit_max_attempts: u64,
97 #[serde(default = "default_market_exit_time_in_force")]
99 #[builder(default = TimeInForce::Gtc)]
100 pub market_exit_time_in_force: TimeInForce,
101 #[serde(default = "default_true")]
103 #[builder(default = true)]
104 pub market_exit_reduce_only: bool,
105 #[serde(default = "default_true")]
108 #[builder(default = true)]
109 pub log_events: bool,
110 #[serde(default = "default_true")]
112 #[builder(default = true)]
113 pub log_commands: bool,
114 #[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 pub fn build(self) -> ConfigResult<StrategyConfig> {
140 let config = self.build_inner();
141 config.validate()?;
142 Ok(config)
143 }
144}
145
146impl StrategyConfig {
147 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#[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 pub strategy_path: String,
199 pub config_path: String,
201 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}