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::DurationNanos;
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    /// Whether to bypass risk checks and order rate limits.
49    #[builder(default)]
50    pub bypass: bool,
51    /// Rate limit for order submission commands.
52    #[builder(default = RateLimit::new(100, DurationNanos::from_secs(1)))]
53    pub max_order_submit: RateLimit,
54    /// Rate limit for order modifications, counting each batch child separately.
55    #[builder(default = RateLimit::new(100, DurationNanos::from_secs(1)))]
56    pub max_order_modify: RateLimit,
57    /// Maximum notional per order by instrument, in each instrument's quote currency.
58    #[builder(default)]
59    pub max_notional_per_order: AHashMap<InstrumentId, Decimal>,
60    /// Venues whose execution clients enforce whole-position conditional exits.
61    ///
62    /// Validated exits skip bounds that apply only to their placeholder quantity and notional.
63    #[builder(default)]
64    pub full_position_exit_venues: AHashSet<Venue>,
65    /// Whether to emit additional debug logs.
66    #[builder(default)]
67    pub debug: bool,
68}
69
70impl<S: risk_engine_config_builder::IsComplete> RiskEngineConfigBuilder<S> {
71    /// Validates and builds the [`RiskEngineConfig`].
72    ///
73    /// # Errors
74    ///
75    /// Returns a [`ConfigError`] if any field fails validation
76    /// (see [`RiskEngineConfig::validate`]).
77    pub fn build(self) -> ConfigResult<RiskEngineConfig> {
78        let config = self.build_inner();
79        config.validate()?;
80        Ok(config)
81    }
82}
83
84impl RiskEngineConfig {
85    /// Validates the risk engine configuration, collecting every field violation.
86    ///
87    /// # Errors
88    ///
89    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
90    /// invalid) if any field fails validation.
91    pub fn validate(&self) -> ConfigResult<()> {
92        let mut errors = ConfigErrorCollector::new();
93
94        for (instrument_id, notional) in &self.max_notional_per_order {
95            errors.check(
96                *notional > Decimal::ZERO,
97                ConfigError::range(
98                    "max_notional_per_order",
99                    format!("notional for {instrument_id} must be positive, was {notional}"),
100                ),
101            );
102        }
103
104        errors.into_result()
105    }
106}
107
108impl Default for RiskEngineConfig {
109    fn default() -> Self {
110        Self::builder()
111            .build()
112            .expect("default `RiskEngineConfig` should be valid")
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use rstest::rstest;
119
120    use super::*;
121
122    #[rstest]
123    fn test_default_config_is_valid() {
124        let config = RiskEngineConfig::builder().build().unwrap();
125
126        assert!(config.full_position_exit_venues.is_empty());
127    }
128
129    #[rstest]
130    #[case(Decimal::ZERO)]
131    #[case(Decimal::from(-1))]
132    fn test_non_positive_notional_rejected(#[case] notional: Decimal) {
133        let mut notionals = AHashMap::new();
134        notionals.insert(InstrumentId::from("ESZ21.GLBX"), notional);
135        let result = RiskEngineConfig::builder()
136            .max_notional_per_order(notionals)
137            .build();
138        assert!(
139            matches!(result, Err(ConfigError::Range { field, .. }) if field == "max_notional_per_order")
140        );
141    }
142
143    #[rstest]
144    fn test_positive_notional_accepted() {
145        let mut notionals = AHashMap::new();
146        notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::from(1_000_000));
147        let result = RiskEngineConfig::builder()
148            .max_notional_per_order(notionals)
149            .build();
150        assert!(result.is_ok());
151    }
152
153    #[rstest]
154    fn test_multiple_violations_collected() {
155        let mut notionals = AHashMap::new();
156        notionals.insert(InstrumentId::from("ESZ21.GLBX"), Decimal::ZERO);
157        notionals.insert(InstrumentId::from("CLZ21.NYMEX"), Decimal::from(-1));
158        let result = RiskEngineConfig::builder()
159            .max_notional_per_order(notionals)
160            .build();
161        let ConfigError::Multiple { errors } = result.unwrap_err() else {
162            panic!("expected ConfigError::Multiple");
163        };
164        assert_eq!(errors.len(), 2);
165        assert!(errors.iter().all(
166            |e| matches!(e, ConfigError::Range { field, .. } if field == "max_notional_per_order")
167        ));
168    }
169}