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;
29
30#[cfg_attr(
32 feature = "python",
33 expect(
34 clippy::unsafe_derive_deserialize,
35 reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
36 )
37)]
38#[derive(Clone, Debug, Deserialize, Serialize, bon::Builder)]
39#[builder(finish_fn(name = build_inner, vis = ""))]
40#[serde(deny_unknown_fields)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.trading", subclass, from_py_object)
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>,
56 #[serde(default = "default_false")]
58 #[builder(default)]
59 pub use_uuid_client_order_ids: bool,
60 #[serde(default = "default_true")]
62 #[builder(default = true)]
63 pub use_hyphens_in_client_order_ids: bool,
64 pub oms_type: Option<OmsType>,
67 pub external_order_claims: Option<Vec<InstrumentId>>,
71 #[serde(default = "default_false")]
74 #[builder(default)]
75 pub manage_contingent_orders: bool,
76 #[serde(default = "default_false")]
79 #[builder(default)]
80 pub manage_gtd_expiry: bool,
81 #[serde(default = "default_false")]
85 #[builder(default)]
86 pub manage_stop: bool,
87 #[serde(default = "default_market_exit_interval_ms")]
90 #[builder(default = 100)]
91 pub market_exit_interval_ms: u64,
92 #[serde(default = "default_market_exit_max_attempts")]
96 #[builder(default = 100)]
97 pub market_exit_max_attempts: u64,
98 #[serde(default = "default_market_exit_time_in_force")]
100 #[builder(default = TimeInForce::Gtc)]
101 pub market_exit_time_in_force: TimeInForce,
102 #[serde(default = "default_true")]
104 #[builder(default = true)]
105 pub market_exit_reduce_only: bool,
106 #[serde(default = "default_true")]
109 #[builder(default = true)]
110 pub log_events: bool,
111 #[serde(default = "default_true")]
113 #[builder(default = true)]
114 pub log_commands: bool,
115 #[serde(default = "default_true")]
117 #[builder(default = true)]
118 pub log_rejected_due_post_only_as_warning: bool,
119}
120
121const fn default_market_exit_interval_ms() -> u64 {
122 100
123}
124
125const fn default_market_exit_max_attempts() -> u64 {
126 100
127}
128
129const fn default_market_exit_time_in_force() -> TimeInForce {
130 TimeInForce::Gtc
131}
132
133impl<S: strategy_config_builder::IsComplete> StrategyConfigBuilder<S> {
134 pub fn build(self) -> ConfigResult<StrategyConfig> {
141 let config = self.build_inner();
142 config.validate()?;
143 Ok(config)
144 }
145}
146
147impl StrategyConfig {
148 pub fn validate(&self) -> ConfigResult<()> {
155 let mut errors = ConfigErrorCollector::new();
156
157 if let Some(order_id_tag) = &self.order_id_tag
158 && let Err(e) = check_order_id_tag(order_id_tag)
159 {
160 errors.push(ConfigError::invalid_value("order_id_tag", e.to_string()));
161 }
162
163 let interval_ms = self.market_exit_interval_ms;
164 errors.check(
165 interval_ms > 0,
166 ConfigError::range(
167 "market_exit_interval_ms",
168 format!("must be a positive number of milliseconds, was {interval_ms}"),
169 ),
170 );
171 errors.check(
172 interval_ms <= MAX_MARKET_EXIT_INTERVAL_MS,
173 ConfigError::range(
174 "market_exit_interval_ms",
175 format!(
176 "must be at most {MAX_MARKET_EXIT_INTERVAL_MS} milliseconds to convert to \
177 nanoseconds without overflow, was {interval_ms}"
178 ),
179 ),
180 );
181
182 let max_attempts = self.market_exit_max_attempts;
183 errors.check(
184 max_attempts > 0,
185 ConfigError::range(
186 "market_exit_max_attempts",
187 format!("must be a positive number of attempts, was {max_attempts}"),
188 ),
189 );
190
191 let time_in_force = self.market_exit_time_in_force;
192 errors.check(
193 time_in_force != TimeInForce::Gtd,
194 ConfigError::unsupported_value(
195 "market_exit_time_in_force",
196 format!("{time_in_force} is not supported for market orders"),
197 ),
198 );
199
200 errors.into_result()
201 }
202}
203
204#[cfg_attr(
206 feature = "python",
207 expect(
208 clippy::unsafe_derive_deserialize,
209 reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
210 )
211)]
212#[derive(Debug, Clone, Deserialize, Serialize)]
213#[serde(deny_unknown_fields)]
214#[cfg_attr(
215 feature = "python",
216 pyo3::pyclass(module = "nautilus_trader.trading", from_py_object)
217)]
218#[cfg_attr(
219 feature = "python",
220 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.trading")
221)]
222pub struct ImportableStrategyConfig {
223 pub strategy_path: String,
225 pub config_path: String,
227 pub config: HashMap<String, serde_json::Value>,
229}
230
231impl Default for StrategyConfig {
232 fn default() -> Self {
233 Self::builder()
234 .build()
235 .expect("default `StrategyConfig` should be valid")
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use rstest::rstest;
242 use strum::IntoEnumIterator;
243
244 use super::*;
245
246 #[rstest]
247 fn test_default_config_is_valid() {
248 assert!(StrategyConfig::builder().build().is_ok());
249 }
250
251 #[rstest]
252 fn test_zero_market_exit_interval_rejected() {
253 let result = StrategyConfig::builder().market_exit_interval_ms(0).build();
254 assert!(
255 matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_interval_ms")
256 );
257 }
258
259 #[rstest]
260 fn test_market_exit_interval_above_nanosecond_bound_rejected() {
261 let result = StrategyConfig::builder()
262 .market_exit_interval_ms(18_446_744_073_710)
263 .build();
264 let Err(ConfigError::Range { field, reason }) = result else {
265 panic!("expected ConfigError::Range");
266 };
267 assert_eq!(field, "market_exit_interval_ms");
268 assert_eq!(
269 reason,
270 "must be at most 18446744073709 milliseconds to convert to nanoseconds without overflow, \
271 was 18446744073710"
272 );
273 }
274
275 #[rstest]
276 fn test_market_exit_interval_at_nanosecond_bound_accepted() {
277 let config = StrategyConfig::builder()
278 .market_exit_interval_ms(18_446_744_073_709)
279 .build();
280
281 assert!(config.is_ok());
282 }
283
284 #[rstest]
285 fn test_zero_market_exit_max_attempts_rejected() {
286 let result = StrategyConfig::builder()
287 .market_exit_max_attempts(0)
288 .build();
289 assert!(
290 matches!(result, Err(ConfigError::Range { field, .. }) if field == "market_exit_max_attempts")
291 );
292 }
293
294 #[rstest]
295 #[case("001")]
296 #[case("ABC")]
297 fn test_order_id_tag_without_separator_accepted(#[case] order_id_tag: &str) {
298 let config = StrategyConfig::builder()
299 .order_id_tag(order_id_tag.to_string())
300 .build()
301 .unwrap();
302
303 assert_eq!(config.order_id_tag.as_deref(), Some(order_id_tag));
304 }
305
306 #[rstest]
307 #[case("A-B")]
308 #[case("XNAS-T01")]
309 fn test_order_id_tag_with_separator_rejected(#[case] order_id_tag: &str) {
310 let result = StrategyConfig::builder()
311 .order_id_tag(order_id_tag.to_string())
312 .build();
313
314 let ConfigError::InvalidValue { field, reason } = result.unwrap_err() else {
315 panic!("expected ConfigError::InvalidValue");
316 };
317 assert_eq!(field, "order_id_tag");
318 assert_eq!(
319 reason,
320 format!(
321 "`order_id_tag` cannot contain the '-' strategy ID separator, was '{order_id_tag}'"
322 )
323 );
324 }
325
326 #[rstest]
327 fn test_gtd_market_exit_time_in_force_rejected() {
328 let result = StrategyConfig::builder()
329 .market_exit_time_in_force(TimeInForce::Gtd)
330 .build();
331 let Err(ConfigError::UnsupportedValue { field, reason }) = result else {
332 panic!("expected ConfigError::UnsupportedValue");
333 };
334 assert_eq!(field, "market_exit_time_in_force");
335 assert_eq!(reason, "GTD is not supported for market orders");
336 }
337
338 #[rstest]
342 fn test_non_gtd_market_exit_time_in_force_accepted() {
343 for time_in_force in TimeInForce::iter().filter(|t| *t != TimeInForce::Gtd) {
344 assert!(
345 StrategyConfig::builder()
346 .market_exit_time_in_force(time_in_force)
347 .build()
348 .is_ok(),
349 "{time_in_force} should be accepted"
350 );
351 }
352 }
353
354 #[rstest]
355 fn test_multiple_violations_collected() {
356 let result = StrategyConfig::builder()
357 .market_exit_interval_ms(0)
358 .market_exit_max_attempts(0)
359 .market_exit_time_in_force(TimeInForce::Gtd)
360 .build();
361 let ConfigError::Multiple { errors } = result.unwrap_err() else {
362 panic!("expected ConfigError::Multiple");
363 };
364 assert_eq!(errors.len(), 3);
367 assert!(matches!(
368 &errors[0],
369 ConfigError::Range { field, .. } if field == "market_exit_interval_ms"
370 ));
371 assert!(matches!(
372 &errors[1],
373 ConfigError::Range { field, .. } if field == "market_exit_max_attempts"
374 ));
375 assert!(matches!(
376 &errors[2],
377 ConfigError::UnsupportedValue { field, .. } if field == "market_exit_time_in_force"
378 ));
379 }
380
381 #[rstest]
382 fn test_strategy_config_default() {
383 let config = StrategyConfig::default();
384
385 assert!(config.strategy_id.is_none());
386 assert!(config.order_id_tag.is_none());
387 assert!(!config.use_uuid_client_order_ids);
388 assert!(config.use_hyphens_in_client_order_ids);
389 assert!(config.oms_type.is_none());
390 assert!(config.external_order_claims.is_none());
391 assert!(!config.manage_contingent_orders);
392 assert!(!config.manage_gtd_expiry);
393 assert!(!config.manage_stop);
394 assert_eq!(config.market_exit_interval_ms, 100);
395 assert_eq!(config.market_exit_max_attempts, 100);
396 assert_eq!(config.market_exit_time_in_force, TimeInForce::Gtc);
397 assert!(config.market_exit_reduce_only);
398 assert!(config.log_events);
399 assert!(config.log_commands);
400 assert!(config.log_rejected_due_post_only_as_warning);
401 }
402
403 #[rstest]
404 fn test_strategy_config_with_strategy_id() {
405 let strategy_id = StrategyId::from("TEST-001");
406 let config = StrategyConfig {
407 strategy_id: Some(strategy_id),
408 ..Default::default()
409 };
410
411 assert_eq!(config.strategy_id, Some(strategy_id));
412 }
413
414 #[rstest]
415 fn test_strategy_config_serialization() {
416 let config = StrategyConfig {
417 strategy_id: Some(StrategyId::from("TEST-001")),
418 order_id_tag: Some("TAG1".to_string()),
419 use_uuid_client_order_ids: true,
420 ..Default::default()
421 };
422
423 let json = serde_json::to_string(&config).unwrap();
424 let deserialized: StrategyConfig = serde_json::from_str(&json).unwrap();
425
426 assert_eq!(config.strategy_id, deserialized.strategy_id);
427 assert_eq!(config.order_id_tag, deserialized.order_id_tag);
428 assert_eq!(
429 config.use_uuid_client_order_ids,
430 deserialized.use_uuid_client_order_ids
431 );
432 }
433}