Skip to main content

nautilus_risk/engine/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides a configuration for `RiskEngine` instances.
17
18use ahash::{AHashMap, AHashSet};
19use nautilus_common::{
20    config::{ConfigError, ConfigErrorCollector, ConfigResult},
21    throttler::RateLimit,
22};
23use nautilus_core::datetime::NANOSECONDS_IN_SECOND;
24use nautilus_model::identifiers::{InstrumentId, Venue};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28/// Configuration for `RiskEngineConfig` instances.
29#[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)]
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    /// Venues whose execution clients enforce whole-position conditional exits.
57    ///
58    /// Validated exits skip bounds that apply only to their placeholder quantity and notional.
59    #[builder(default)]
60    pub full_position_exit_venues: AHashSet<Venue>,
61    #[builder(default)]
62    pub debug: bool,
63}
64
65impl<S: risk_engine_config_builder::IsComplete> RiskEngineConfigBuilder<S> {
66    /// Validates and builds the [`RiskEngineConfig`].
67    ///
68    /// # Errors
69    ///
70    /// Returns a [`ConfigError`] if any field fails validation
71    /// (see [`RiskEngineConfig::validate`]).
72    pub fn build(self) -> ConfigResult<RiskEngineConfig> {
73        let config = self.build_inner();
74        config.validate()?;
75        Ok(config)
76    }
77}
78
79impl RiskEngineConfig {
80    /// Validates the risk engine configuration, collecting every field violation.
81    ///
82    /// # Errors
83    ///
84    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
85    /// invalid) if any field fails validation.
86    pub fn validate(&self) -> ConfigResult<()> {
87        let mut errors = ConfigErrorCollector::new();
88
89        for (instrument_id, notional) in &self.max_notional_per_order {
90            errors.check(
91                *notional > Decimal::ZERO,
92                ConfigError::range(
93                    "max_notional_per_order",
94                    format!("notional for {instrument_id} must be positive, was {notional}"),
95                ),
96            );
97        }
98
99        errors.into_result()
100    }
101}
102
103impl Default for RiskEngineConfig {
104    fn default() -> Self {
105        Self::builder()
106            .build()
107            .expect("default `RiskEngineConfig` should be valid")
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use rstest::rstest;
114
115    use super::*;
116
117    #[rstest]
118    fn test_default_config_is_valid() {
119        let config = RiskEngineConfig::builder().build().unwrap();
120
121        assert!(config.full_position_exit_venues.is_empty());
122    }
123
124    #[rstest]
125    #[case(Decimal::ZERO)]
126    #[case(Decimal::from(-1))]
127    fn test_non_positive_notional_rejected(#[case] notional: Decimal) {
128        let mut notionals = AHashMap::new();
129        notionals.insert(InstrumentId::from("ESZ21.GLBX"), notional);
130        let result = RiskEngineConfig::builder()
131            .max_notional_per_order(notionals)
132            .build();
133        assert!(
134            matches!(result, Err(ConfigError::Range { field, .. }) if field == "max_notional_per_order")
135        );
136    }
137
138    #[rstest]
139    fn test_positive_notional_accepted() {
140        let mut notionals = AHashMap::new();
141        notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(1_000_000));
142        let result = RiskEngineConfig::builder()
143            .max_notional_per_order(notionals)
144            .build();
145        assert!(result.is_ok());
146    }
147
148    #[rstest]
149    fn test_multiple_violations_collected() {
150        let mut notionals = AHashMap::new();
151        notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
152        notionals.insert(InstrumentId::from("CLZ21.NYMEX"), Decimal::from(-1));
153        let result = RiskEngineConfig::builder()
154            .max_notional_per_order(notionals)
155            .build();
156        let ConfigError::Multiple { errors } = result.unwrap_err() else {
157            panic!("expected ConfigError::Multiple");
158        };
159        assert_eq!(errors.len(), 2);
160        assert!(errors.iter().all(
161            |e| matches!(e, ConfigError::Range { field, .. } if field == "max_notional_per_order")
162        ));
163    }
164}