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> {
139 let config = self.build_inner();
140 config.validate()?;
141 Ok(config)
142 }
143}
144
145impl StrategyConfig {
146 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#[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 pub strategy_path: String,
223 pub config_path: String,
225 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 #[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 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}