nautilus_risk/engine/
config.rs1use ahash::{AHashMap, AHashSet};
19use nautilus_common::{
20 config::{ConfigError, ConfigErrorCollector, ConfigResult},
21 throttler::RateLimit,
22};
23use nautilus_core::DurationNanos;
24use nautilus_model::identifiers::{InstrumentId, Venue};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28#[cfg_attr(
30 feature = "python",
31 pyo3::pyclass(module = "nautilus_trader.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)]
50 pub bypass: bool,
51 #[builder(default = RateLimit::new(100, DurationNanos::from_secs(1)))]
53 pub max_order_submit: RateLimit,
54 #[builder(default = RateLimit::new(100, DurationNanos::from_secs(1)))]
56 pub max_order_modify: RateLimit,
57 #[builder(default)]
59 pub max_notional_per_order: AHashMap<InstrumentId, Decimal>,
60 #[builder(default)]
64 pub full_position_exit_venues: AHashSet<Venue>,
65 #[builder(default)]
67 pub debug: bool,
68}
69
70impl<S: risk_engine_config_builder::IsComplete> RiskEngineConfigBuilder<S> {
71 pub fn build(self) -> ConfigResult<RiskEngineConfig> {
77 let config = self.build_inner();
78 config.validate()?;
79 Ok(config)
80 }
81}
82
83impl RiskEngineConfig {
84 pub fn validate(&self) -> ConfigResult<()> {
91 let mut errors = ConfigErrorCollector::new();
92
93 for (instrument_id, notional) in &self.max_notional_per_order {
94 errors.check(
95 *notional > Decimal::ZERO,
96 ConfigError::range(
97 "max_notional_per_order",
98 format!("notional for {instrument_id} must be positive, was {notional}"),
99 ),
100 );
101 }
102
103 errors.into_result()
104 }
105}
106
107impl Default for RiskEngineConfig {
108 fn default() -> Self {
109 Self::builder()
110 .build()
111 .expect("default `RiskEngineConfig` should be valid")
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use rstest::rstest;
118
119 use super::*;
120
121 #[rstest]
122 fn test_default_config_is_valid() {
123 let config = RiskEngineConfig::builder().build().unwrap();
124
125 assert!(config.full_position_exit_venues.is_empty());
126 }
127
128 #[rstest]
129 #[case(Decimal::ZERO)]
130 #[case(Decimal::from(-1))]
131 fn test_non_positive_notional_rejected(#[case] notional: Decimal) {
132 let mut notionals = AHashMap::new();
133 notionals.insert(InstrumentId::from("ESZ21.GLBX"), notional);
134 let result = RiskEngineConfig::builder()
135 .max_notional_per_order(notionals)
136 .build();
137 assert!(
138 matches!(result, Err(ConfigError::Range { field, .. }) if field == "max_notional_per_order")
139 );
140 }
141
142 #[rstest]
143 fn test_positive_notional_accepted() {
144 let mut notionals = AHashMap::new();
145 notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(1_000_000));
146 let result = RiskEngineConfig::builder()
147 .max_notional_per_order(notionals)
148 .build();
149 assert!(result.is_ok());
150 }
151
152 #[rstest]
153 fn test_multiple_violations_collected() {
154 let mut notionals = AHashMap::new();
155 notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
156 notionals.insert(InstrumentId::from("CLZ21.NYMEX"), Decimal::from(-1));
157 let result = RiskEngineConfig::builder()
158 .max_notional_per_order(notionals)
159 .build();
160 let ConfigError::Multiple { errors } = result.unwrap_err() else {
161 panic!("expected ConfigError::Multiple");
162 };
163 assert_eq!(errors.len(), 2);
164 assert!(errors.iter().all(
165 |e| matches!(e, ConfigError::Range { field, .. } if field == "max_notional_per_order")
166 ));
167 }
168}