nautilus_risk/engine/
config.rs1use ahash::AHashMap;
19use nautilus_common::{
20 config::{ConfigError, ConfigErrorCollector, ConfigResult},
21 throttler::RateLimit,
22};
23use nautilus_core::datetime::NANOSECONDS_IN_SECOND;
24use nautilus_model::identifiers::InstrumentId;
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28#[cfg_attr(
30 feature = "python",
31 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.risk", from_py_object)
32)]
33#[cfg_attr(
34 feature = "python",
35 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.risk")
36)]
37#[cfg_attr(
38 feature = "python",
39 expect(
40 clippy::unsafe_derive_deserialize,
41 reason = "config deserializes plain fields; unsafe methods come from generated PyO3 integration"
42 )
43)]
44#[derive(Debug, Clone, Deserialize, Serialize, bon::Builder)]
45#[builder(finish_fn(name = build_inner, vis = ""))]
46#[serde(default, deny_unknown_fields)]
47pub struct RiskEngineConfig {
48 #[builder(default)]
49 pub bypass: bool,
50 #[builder(default = RateLimit::new(100, NANOSECONDS_IN_SECOND))]
51 pub max_order_submit: RateLimit,
52 #[builder(default = RateLimit::new(100, NANOSECONDS_IN_SECOND))]
53 pub max_order_modify: RateLimit,
54 #[builder(default)]
55 pub max_notional_per_order: AHashMap<InstrumentId, Decimal>,
56 #[builder(default)]
57 pub debug: bool,
58}
59
60impl<S: risk_engine_config_builder::IsComplete> RiskEngineConfigBuilder<S> {
61 pub fn build(self) -> ConfigResult<RiskEngineConfig> {
68 let config = self.build_inner();
69 config.validate()?;
70 Ok(config)
71 }
72}
73
74impl RiskEngineConfig {
75 pub fn validate(&self) -> ConfigResult<()> {
82 let mut errors = ConfigErrorCollector::new();
83
84 for (instrument_id, notional) in &self.max_notional_per_order {
85 errors.check(
86 *notional > Decimal::ZERO,
87 ConfigError::range(
88 "max_notional_per_order",
89 format!("notional for {instrument_id} must be positive, was {notional}"),
90 ),
91 );
92 }
93
94 errors.into_result()
95 }
96}
97
98impl Default for RiskEngineConfig {
99 fn default() -> Self {
100 Self::builder()
101 .build()
102 .expect("default `RiskEngineConfig` should be valid")
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use rstest::rstest;
109
110 use super::*;
111
112 #[rstest]
113 fn test_default_config_is_valid() {
114 assert!(RiskEngineConfig::builder().build().is_ok());
115 }
116
117 #[rstest]
118 #[case(Decimal::ZERO)]
119 #[case(Decimal::from(-1))]
120 fn test_non_positive_notional_rejected(#[case] notional: Decimal) {
121 let mut notionals = AHashMap::new();
122 notionals.insert(InstrumentId::from("ESZ21.GLBX"), notional);
123 let result = RiskEngineConfig::builder()
124 .max_notional_per_order(notionals)
125 .build();
126 assert!(
127 matches!(result, Err(ConfigError::Range { field, .. }) if field == "max_notional_per_order")
128 );
129 }
130
131 #[rstest]
132 fn test_positive_notional_accepted() {
133 let mut notionals = AHashMap::new();
134 notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(1_000_000));
135 let result = RiskEngineConfig::builder()
136 .max_notional_per_order(notionals)
137 .build();
138 assert!(result.is_ok());
139 }
140
141 #[rstest]
142 fn test_multiple_violations_collected() {
143 let mut notionals = AHashMap::new();
144 notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
145 notionals.insert(InstrumentId::from("CLZ21.NYMEX"), Decimal::from(-1));
146 let result = RiskEngineConfig::builder()
147 .max_notional_per_order(notionals)
148 .build();
149 let ConfigError::Multiple { errors } = result.unwrap_err() else {
150 panic!("expected ConfigError::Multiple");
151 };
152 assert_eq!(errors.len(), 2);
153 assert!(errors.iter().all(
154 |e| matches!(e, ConfigError::Range { field, .. } if field == "max_notional_per_order")
155 ));
156 }
157}