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::{datetime::NANOSECONDS_IN_SECOND, 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() / NANOSECONDS_IN_SECOND;
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    RateLimit::new_checked(limit, total_secs.saturating_mul(NANOSECONDS_IN_SECOND))
69        .map_err(|e| to_pyvalue_err(format!("invalid `{name}`: {e}")))
70}
71
72fn coerce_max_notional_per_order(
73    raw: HashMap<String, Py<PyAny>>,
74) -> PyResult<AHashMap<InstrumentId, Decimal>> {
75    Python::attach(|py| -> PyResult<AHashMap<InstrumentId, Decimal>> {
76        let mut result = AHashMap::with_capacity(raw.len());
77        for (instrument_id, value) in raw {
78            let parsed_id = InstrumentId::from_str(&instrument_id).map_err(|e| {
79                to_pyvalue_err(format!(
80                    "invalid `max_notional_per_order` instrument ID {instrument_id:?}: {e}"
81                ))
82            })?;
83            let value_str: String = value.bind(py).str()?.extract()?;
84            let notional = Decimal::from_str(&value_str).map_err(|e| {
85                to_pyvalue_err(format!(
86                    "invalid `max_notional_per_order` notional {value_str:?}: {e}"
87                ))
88            })?;
89            result.insert(parsed_id, notional);
90        }
91        Ok(result)
92    })
93}
94
95#[pymethods]
96#[pyo3_stub_gen::derive::gen_stub_pymethods]
97impl RiskEngineConfig {
98    /// Configuration for `RiskEngine` instances.
99    #[new]
100    #[pyo3(signature = (
101        bypass = None,
102        max_order_submit_rate = None,
103        max_order_modify_rate = None,
104        max_notional_per_order = None,
105        full_position_exit_venues = None,
106        debug = None,
107    ))]
108    fn py_new(
109        bypass: Option<bool>,
110        max_order_submit_rate: Option<String>,
111        max_order_modify_rate: Option<String>,
112        max_notional_per_order: Option<HashMap<String, Py<PyAny>>>,
113        full_position_exit_venues: Option<Vec<Venue>>,
114        debug: Option<bool>,
115    ) -> PyResult<Self> {
116        let default = Self::default();
117
118        let max_order_submit = match max_order_submit_rate {
119            Some(value) => parse_rate_limit("max_order_submit_rate", &value)?,
120            None => default.max_order_submit,
121        };
122        let max_order_modify = match max_order_modify_rate {
123            Some(value) => parse_rate_limit("max_order_modify_rate", &value)?,
124            None => default.max_order_modify,
125        };
126        let max_notional_per_order = match max_notional_per_order {
127            Some(raw) => coerce_max_notional_per_order(raw)?,
128            None => default.max_notional_per_order,
129        };
130        let full_position_exit_venues = full_position_exit_venues
131            .map(|venues| venues.into_iter().collect())
132            .unwrap_or(default.full_position_exit_venues);
133
134        Self::builder()
135            .bypass(bypass.unwrap_or(default.bypass))
136            .max_order_submit(max_order_submit)
137            .max_order_modify(max_order_modify)
138            .max_notional_per_order(max_notional_per_order)
139            .full_position_exit_venues(full_position_exit_venues)
140            .debug(debug.unwrap_or(default.debug))
141            .build()
142            .map_err(to_pyvalue_err)
143    }
144
145    #[getter]
146    #[pyo3(name = "bypass")]
147    const fn py_bypass(&self) -> bool {
148        self.bypass
149    }
150
151    #[getter]
152    #[pyo3(name = "max_order_submit_rate")]
153    fn py_max_order_submit_rate(&self) -> String {
154        format_rate_limit(&self.max_order_submit)
155    }
156
157    #[getter]
158    #[pyo3(name = "max_order_modify_rate")]
159    fn py_max_order_modify_rate(&self) -> String {
160        format_rate_limit(&self.max_order_modify)
161    }
162
163    #[getter]
164    #[pyo3(name = "max_notional_per_order")]
165    fn py_max_notional_per_order(&self) -> HashMap<String, String> {
166        self.max_notional_per_order
167            .iter()
168            .map(|(id, notional)| (id.to_string(), notional.to_string()))
169            .collect()
170    }
171
172    #[getter]
173    #[pyo3(name = "full_position_exit_venues")]
174    fn py_full_position_exit_venues(&self) -> Vec<Venue> {
175        let mut venues = self
176            .full_position_exit_venues
177            .iter()
178            .copied()
179            .collect::<Vec<_>>();
180        venues.sort_unstable();
181        venues
182    }
183
184    #[getter]
185    #[pyo3(name = "debug")]
186    const fn py_debug(&self) -> bool {
187        self.debug
188    }
189
190    fn __repr__(&self) -> String {
191        format!("{self:?}")
192    }
193
194    fn __str__(&self) -> String {
195        format!("{self:?}")
196    }
197}