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, check_order_id_tag},
23};
24use serde::{Deserialize, Serialize};
25
26const MAX_MARKET_EXIT_INTERVAL_MS: u64 = u64::MAX / 1_000_000;
28
29#[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 pub strategy_id: Option<StrategyId>,
51 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_instrument_ids: Option<Vec<InstrumentId>>,
69 #[serde(default = "default_false")]
72 #[builder(default)]
73 pub manage_contingent_orders: bool,
74 #[serde(default = "default_false")]
77 #[builder(default)]
78 pub manage_gtd_expiry: bool,
79 #[serde(default = "default_false")]
83 #[builder(default)]
84 pub manage_stop: bool,
85 #[serde(default = "default_market_exit_interval_ms")]
88 #[builder(default = 100)]
89 pub market_exit_interval_ms: u64,
90 #[serde(default = "default_market_exit_max_attempts")]
94 #[builder(default = 100)]
95 pub market_exit_max_attempts: u64,
96 #[serde(default = "default_market_exit_time_in_force")]
98 #[builder(default = TimeInForce::Gtc)]
99 pub market_exit_time_in_force: TimeInForce,
100 #[serde(default = "default_true")]
102 #[builder(default = true)]
103 pub market_exit_reduce_only: bool,
104 #[serde(default = "default_true")]
107 #[builder(default = true)]
108 pub log_events: bool,
109 #[serde(default = "default_true")]
111 #[builder(default = true)]
112 pub log_commands: bool,
113 #[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 pub fn build(self) -> ConfigResult<StrategyConfig> {
138 let config = self.build_inner();
139 config.validate()?;
140 Ok(config)
141 }
142}
143
144impl StrategyConfig {
145 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#[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 pub strategy_path: String,
222 pub config_path: String,
224 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 #[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 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}