nautilus_risk/python/
config.rs1use 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 #[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}