nautilus_model/python/types/
balance.rs1use std::{
17 collections::hash_map::DefaultHasher,
18 hash::{Hash, Hasher},
19 str::FromStr,
20};
21
22use nautilus_core::python::{
23 parsing::{get_optional_parsed, get_required_parsed, get_required_string},
24 to_pyvalue_err,
25};
26use pyo3::{prelude::*, types::PyDict};
27
28use crate::{
29 identifiers::InstrumentId,
30 types::{AccountBalance, Currency, MarginBalance, Money},
31};
32
33#[pyo3_stub_gen::derive::gen_stub_pymethods]
34#[pymethods]
35impl AccountBalance {
36 #[new]
38 fn py_new(total: Money, locked: Money, free: Money) -> PyResult<Self> {
39 Self::new_checked(total, locked, free).map_err(to_pyvalue_err)
40 }
41
42 fn __repr__(&self) -> String {
43 format!("{self:?}")
44 }
45
46 fn __str__(&self) -> String {
47 self.to_string()
48 }
49
50 fn __hash__(&self) -> isize {
51 let mut h = DefaultHasher::new();
52 self.total.raw().hash(&mut h);
53 self.locked.raw().hash(&mut h);
54 self.free.raw().hash(&mut h);
55 self.currency.code.hash(&mut h);
56 h.finish() as isize
57 }
58
59 #[getter]
60 #[pyo3(name = "total")]
61 fn py_total(&self) -> Money {
62 self.total
63 }
64
65 #[getter]
66 #[pyo3(name = "locked")]
67 fn py_locked(&self) -> Money {
68 self.locked
69 }
70
71 #[getter]
72 #[pyo3(name = "free")]
73 fn py_free(&self) -> Money {
74 self.free
75 }
76
77 #[getter]
78 #[pyo3(name = "currency")]
79 fn py_currency(&self) -> Currency {
80 self.currency
81 }
82
83 #[pyo3(name = "copy")]
85 fn py_copy(&self) -> Self {
86 *self
87 }
88
89 #[staticmethod]
95 #[pyo3(name = "from_dict")]
96 pub fn py_from_dict(values: &Bound<'_, PyDict>) -> PyResult<Self> {
97 let currency_str = get_required_string(values, "currency")?;
98 let currency = Currency::from_str(currency_str.as_str()).map_err(to_pyvalue_err)?;
99 let total = money_from_dict(values, "total", currency)?;
100 let locked = money_from_dict(values, "locked", currency)?;
101 let free = money_from_dict(values, "free", currency)?;
102 Self::new_checked(total, locked, free).map_err(to_pyvalue_err)
103 }
104
105 #[pyo3(name = "to_dict")]
111 pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
112 let dict = PyDict::new(py);
113 dict.set_item("type", stringify!(AccountBalance))?;
114 dict.set_item("total", self.total.to_decimal_string())?;
115 dict.set_item("locked", self.locked.to_decimal_string())?;
116 dict.set_item("free", self.free.to_decimal_string())?;
117 dict.set_item("currency", self.currency.code.to_string())?;
118 Ok(dict.into())
119 }
120}
121
122#[pyo3_stub_gen::derive::gen_stub_pymethods]
123#[pymethods]
124impl MarginBalance {
125 #[new]
136 #[pyo3(signature = (initial, maintenance, instrument_id=None))]
137 fn py_new(
138 initial: Money,
139 maintenance: Money,
140 instrument_id: Option<InstrumentId>,
141 ) -> PyResult<Self> {
142 Self::new_checked(initial, maintenance, instrument_id).map_err(to_pyvalue_err)
143 }
144
145 fn __repr__(&self) -> String {
146 format!("{self:?}")
147 }
148
149 fn __str__(&self) -> String {
150 self.to_string()
151 }
152
153 fn __hash__(&self) -> isize {
154 let mut h = DefaultHasher::new();
155 self.initial.raw().hash(&mut h);
156 self.maintenance.raw().hash(&mut h);
157 self.currency.code.hash(&mut h);
158 self.instrument_id.hash(&mut h);
159 h.finish() as isize
160 }
161
162 #[getter]
163 #[pyo3(name = "initial")]
164 fn py_initial(&self) -> Money {
165 self.initial
166 }
167
168 #[getter]
169 #[pyo3(name = "maintenance")]
170 fn py_maintenance(&self) -> Money {
171 self.maintenance
172 }
173
174 #[getter]
175 #[pyo3(name = "currency")]
176 fn py_currency(&self) -> Currency {
177 self.currency
178 }
179
180 #[getter]
181 #[pyo3(name = "instrument_id")]
182 fn py_instrument_id(&self) -> Option<InstrumentId> {
183 self.instrument_id
184 }
185
186 #[pyo3(name = "copy")]
188 fn py_copy(&self) -> Self {
189 *self
190 }
191
192 #[staticmethod]
198 #[pyo3(name = "from_dict")]
199 pub fn py_from_dict(values: &Bound<'_, PyDict>) -> PyResult<Self> {
200 let currency_str = get_required_string(values, "currency")?;
201 let currency = Currency::from_str(currency_str.as_str()).map_err(to_pyvalue_err)?;
202 let initial = money_from_dict(values, "initial", currency)?;
203 let maintenance = money_from_dict(values, "maintenance", currency)?;
204
205 let instrument_id = get_optional_parsed(values, "instrument_id", |s| {
206 InstrumentId::from_str(&s).map_err(|e| e.to_string())
207 })?;
208
209 Self::new_checked(initial, maintenance, instrument_id).map_err(to_pyvalue_err)
210 }
211
212 #[pyo3(name = "to_dict")]
218 pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
219 let dict = PyDict::new(py);
220 dict.set_item("type", stringify!(MarginBalance))?;
221 dict.set_item("initial", self.initial.to_decimal_string())?;
222 dict.set_item("maintenance", self.maintenance.to_decimal_string())?;
223 dict.set_item("currency", self.currency.code.to_string())?;
224 match self.instrument_id {
225 Some(id) => dict.set_item("instrument_id", id.to_string())?,
226 None => dict.set_item("instrument_id", py.None())?,
227 }
228 Ok(dict.into())
229 }
230}
231
232fn money_from_dict(values: &Bound<'_, PyDict>, field: &str, currency: Currency) -> PyResult<Money> {
233 get_required_parsed(values, field, |value| {
234 Money::from_decimal_str_exact(&value, currency)
235 })
236}
237
238#[cfg(test)]
239mod tests {
240 use pyo3::{Python, types::PyDict};
241 use rstest::rstest;
242
243 use super::*;
244 use crate::types::{
245 fixed::FIXED_PRECISION,
246 money::{MONEY_MAX, MONEY_MIN, MONEY_RAW_MAX, MONEY_RAW_MIN},
247 };
248
249 #[rstest]
250 #[case(
251 "total",
252 "ValueError: Failed to parse 'total': Invalid decimal value 'not-a-number'"
253 )]
254 #[case(
255 "free",
256 "ValueError: Failed to parse 'free': Invalid decimal value 'not-a-number'"
257 )]
258 #[case(
259 "locked",
260 "ValueError: Failed to parse 'locked': Invalid decimal value 'not-a-number'"
261 )]
262 fn test_account_balance_from_dict_rejects_invalid_numeric_field(
263 #[case] field: &str,
264 #[case] expected: &str,
265 ) {
266 Python::initialize();
267 Python::attach(|py| {
268 let values = PyDict::new(py);
269 values.set_item("currency", "USD").unwrap();
270 values.set_item("total", "1.00").unwrap();
271 values.set_item("free", "1.00").unwrap();
272 values.set_item("locked", "0.00").unwrap();
273 values.set_item(field, "not-a-number").unwrap();
274
275 let error = AccountBalance::py_from_dict(&values).unwrap_err();
276
277 assert_eq!(error.to_string(), expected);
278 });
279 }
280
281 #[rstest]
282 fn test_account_balance_from_dict_rejects_out_of_range_money_value() {
283 Python::initialize();
284 Python::attach(|py| {
285 let value = MONEY_MAX + 1.0;
286 let values = PyDict::new(py);
287 values.set_item("currency", "USD").unwrap();
288 values.set_item("total", value.to_string()).unwrap();
289 values.set_item("free", "1.00").unwrap();
290 values.set_item("locked", "0.00").unwrap();
291
292 let error = AccountBalance::py_from_dict(&values).unwrap_err();
293
294 let raw = (value as i128) * 10_i128.pow(u32::from(FIXED_PRECISION));
295
296 let detail = if cfg!(feature = "high-precision") {
297 format!(
298 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
299 )
300 } else {
301 format!("Raw value {raw} exceeds MoneyRaw range for Money")
302 };
303
304 assert_eq!(
305 error.to_string(),
306 format!("ValueError: Failed to parse 'total': {detail}")
307 );
308 });
309 }
310
311 #[rstest]
312 #[case(
313 "initial",
314 "ValueError: Failed to parse 'initial': Invalid decimal value 'not-a-number'"
315 )]
316 #[case(
317 "maintenance",
318 "ValueError: Failed to parse 'maintenance': Invalid decimal value 'not-a-number'"
319 )]
320 fn test_margin_balance_from_dict_rejects_invalid_numeric_field(
321 #[case] field: &str,
322 #[case] expected: &str,
323 ) {
324 Python::initialize();
325 Python::attach(|py| {
326 let values = PyDict::new(py);
327 values.set_item("currency", "USD").unwrap();
328 values.set_item("initial", "1.00").unwrap();
329 values.set_item("maintenance", "0.50").unwrap();
330 values.set_item("instrument_id", py.None()).unwrap();
331 values.set_item(field, "not-a-number").unwrap();
332
333 let error = MarginBalance::py_from_dict(&values).unwrap_err();
334
335 assert_eq!(error.to_string(), expected);
336 });
337 }
338
339 #[rstest]
340 fn test_margin_balance_from_dict_rejects_out_of_range_money_value() {
341 Python::initialize();
342 Python::attach(|py| {
343 let value = MONEY_MIN - 1.0;
344 let values = PyDict::new(py);
345 values.set_item("currency", "USD").unwrap();
346 values.set_item("initial", value.to_string()).unwrap();
347 values.set_item("maintenance", "0.50").unwrap();
348 values.set_item("instrument_id", py.None()).unwrap();
349
350 let error = MarginBalance::py_from_dict(&values).unwrap_err();
351
352 let raw = (value as i128) * 10_i128.pow(u32::from(FIXED_PRECISION));
353
354 let detail = if cfg!(feature = "high-precision") {
355 format!(
356 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
357 )
358 } else {
359 format!("Raw value {raw} exceeds MoneyRaw range for Money")
360 };
361
362 assert_eq!(
363 error.to_string(),
364 format!("ValueError: Failed to parse 'initial': {detail}")
365 );
366 });
367 }
368}