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