Skip to main content

nautilus_risk/python/
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//! Python bindings for risk engine configuration.
17
18use std::{collections::HashMap, str::FromStr};
19
20use ahash::AHashMap;
21use nautilus_common::throttler::RateLimit;
22use nautilus_core::{DurationNanos, python::to_pyvalue_err};
23use nautilus_model::identifiers::{InstrumentId, Venue};
24use pyo3::{Py, PyAny, PyResult, Python, prelude::PyAnyMethods, pymethods};
25use rust_decimal::Decimal;
26
27use crate::engine::config::RiskEngineConfig;
28
29fn format_rate_limit(rate: &RateLimit) -> String {
30    let total_secs = rate.interval_ns().as_secs();
31    let hours = total_secs / 3_600;
32    let minutes = (total_secs % 3_600) / 60;
33    let seconds = total_secs % 60;
34    format!("{}/{hours:02}:{minutes:02}:{seconds:02}", rate.limit())
35}
36
37fn parse_rate_limit(name: &str, value: &str) -> PyResult<RateLimit> {
38    let (limit, interval) = value
39        .split_once('/')
40        .ok_or_else(|| to_pyvalue_err(format!("invalid `{name}`: expected 'limit/HH:MM:SS'")))?;
41
42    let limit = limit
43        .parse::<usize>()
44        .map_err(|e| to_pyvalue_err(format!("invalid `{name}` limit: {e}")))?;
45
46    let mut total_secs: u64 = 0;
47    let mut parts = interval.split(':');
48    for (label, multiplier) in [("hours", 3_600), ("minutes", 60), ("seconds", 1)] {
49        let component = parts
50            .next()
51            .ok_or_else(|| {
52                to_pyvalue_err(format!(
53                    "invalid `{name}`: expected 'limit/HH:MM:SS' interval"
54                ))
55            })?
56            .parse::<u64>()
57            .map_err(|e| to_pyvalue_err(format!("invalid `{name}` {label}: {e}")))?;
58
59        total_secs = total_secs.saturating_add(component.saturating_mul(multiplier));
60    }
61
62    if parts.next().is_some() {
63        return Err(to_pyvalue_err(format!(
64            "invalid `{name}`: expected 'limit/HH:MM:SS'"
65        )));
66    }
67
68    let interval_ns = DurationNanos::try_from_secs(total_secs)
69        .map_err(|e| to_pyvalue_err(format!("invalid `{name}`: {e}")))?;
70    RateLimit::new_checked(limit, interval_ns)
71        .map_err(|e| to_pyvalue_err(format!("invalid `{name}`: {e}")))
72}
73
74fn coerce_max_notional_per_order(
75    raw: HashMap<String, Py<PyAny>>,
76) -> PyResult<AHashMap<InstrumentId, Decimal>> {
77    Python::attach(|py| -> PyResult<AHashMap<InstrumentId, Decimal>> {
78        let mut result = AHashMap::with_capacity(raw.len());
79        for (instrument_id, value) in raw {
80            let parsed_id = InstrumentId::from_str(&instrument_id).map_err(|e| {
81                to_pyvalue_err(format!(
82                    "invalid `max_notional_per_order` instrument ID {instrument_id:?}: {e}"
83                ))
84            })?;
85            let value_str: String = value.bind(py).str()?.extract()?;
86            let notional = Decimal::from_str(&value_str).map_err(|e| {
87                to_pyvalue_err(format!(
88                    "invalid `max_notional_per_order` notional {value_str:?}: {e}"
89                ))
90            })?;
91            result.insert(parsed_id, notional);
92        }
93        Ok(result)
94    })
95}
96
97#[pymethods]
98#[pyo3_stub_gen::derive::gen_stub_pymethods]
99impl RiskEngineConfig {
100    /// Configuration for `RiskEngine` instances.
101    #[new]
102    #[pyo3(signature = (
103        bypass = None,
104        max_order_submit_rate = None,
105        max_order_modify_rate = None,
106        max_notional_per_order = None,
107        full_position_exit_venues = None,
108        debug = None,
109    ))]
110    fn py_new(
111        bypass: Option<bool>,
112        max_order_submit_rate: Option<String>,
113        max_order_modify_rate: Option<String>,
114        max_notional_per_order: Option<HashMap<String, Py<PyAny>>>,
115        full_position_exit_venues: Option<Vec<Venue>>,
116        debug: Option<bool>,
117    ) -> PyResult<Self> {
118        let default = Self::default();
119
120        let max_order_submit = match max_order_submit_rate {
121            Some(value) => parse_rate_limit("max_order_submit_rate", &value)?,
122            None => default.max_order_submit,
123        };
124        let max_order_modify = match max_order_modify_rate {
125            Some(value) => parse_rate_limit("max_order_modify_rate", &value)?,
126            None => default.max_order_modify,
127        };
128        let max_notional_per_order = match max_notional_per_order {
129            Some(raw) => coerce_max_notional_per_order(raw)?,
130            None => default.max_notional_per_order,
131        };
132        let full_position_exit_venues = full_position_exit_venues
133            .map(|venues| venues.into_iter().collect())
134            .unwrap_or(default.full_position_exit_venues);
135
136        Self::builder()
137            .bypass(bypass.unwrap_or(default.bypass))
138            .max_order_submit(max_order_submit)
139            .max_order_modify(max_order_modify)
140            .max_notional_per_order(max_notional_per_order)
141            .full_position_exit_venues(full_position_exit_venues)
142            .debug(debug.unwrap_or(default.debug))
143            .build()
144            .map_err(to_pyvalue_err)
145    }
146
147    #[getter]
148    #[pyo3(name = "bypass")]
149    const fn py_bypass(&self) -> bool {
150        self.bypass
151    }
152
153    #[getter]
154    #[pyo3(name = "max_order_submit_rate")]
155    fn py_max_order_submit_rate(&self) -> String {
156        format_rate_limit(&self.max_order_submit)
157    }
158
159    #[getter]
160    #[pyo3(name = "max_order_modify_rate")]
161    fn py_max_order_modify_rate(&self) -> String {
162        format_rate_limit(&self.max_order_modify)
163    }
164
165    #[getter]
166    #[pyo3(name = "max_notional_per_order")]
167    fn py_max_notional_per_order(&self) -> HashMap<String, String> {
168        self.max_notional_per_order
169            .iter()
170            .map(|(id, notional)| (id.to_string(), notional.to_string()))
171            .collect()
172    }
173
174    #[getter]
175    #[pyo3(name = "full_position_exit_venues")]
176    fn py_full_position_exit_venues(&self) -> Vec<Venue> {
177        let mut venues = self
178            .full_position_exit_venues
179            .iter()
180            .copied()
181            .collect::<Vec<_>>();
182        venues.sort_unstable();
183        venues
184    }
185
186    #[getter]
187    #[pyo3(name = "debug")]
188    const fn py_debug(&self) -> bool {
189        self.debug
190    }
191
192    fn __repr__(&self) -> String {
193        format!("{self:?}")
194    }
195
196    fn __str__(&self) -> String {
197        format!("{self:?}")
198    }
199}