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_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#[pymethods]
34#[pyo3_stub_gen::derive::gen_stub_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 #[pyo3(name = "copy")]
61 fn py_copy(&self) -> Self {
62 *self
63 }
64
65 #[staticmethod]
71 #[pyo3(name = "from_dict")]
72 pub fn py_from_dict(values: &Bound<'_, PyDict>) -> PyResult<Self> {
73 let currency_str = get_required_string(values, "currency")?;
74 let total_str = get_required_string(values, "total")?;
75 let total: f64 = total_str.parse::<f64>().map_err(|e| {
76 to_pyvalue_err(format!("invalid AccountBalance total '{total_str}': {e}"))
77 })?;
78 let free_str = get_required_string(values, "free")?;
79 let free: f64 = free_str.parse::<f64>().map_err(|e| {
80 to_pyvalue_err(format!("invalid AccountBalance free '{free_str}': {e}"))
81 })?;
82 let locked_str = get_required_string(values, "locked")?;
83 let locked: f64 = locked_str.parse::<f64>().map_err(|e| {
84 to_pyvalue_err(format!("invalid AccountBalance locked '{locked_str}': {e}"))
85 })?;
86 let currency = Currency::from_str(currency_str.as_str()).map_err(to_pyvalue_err)?;
87 Self::new_checked(
88 Money::new(total, currency),
89 Money::new(locked, currency),
90 Money::new(free, currency),
91 )
92 .map_err(to_pyvalue_err)
93 }
94
95 #[pyo3(name = "to_dict")]
101 pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
102 let dict = PyDict::new(py);
103 dict.set_item("type", stringify!(AccountBalance))?;
104 dict.set_item(
105 "total",
106 format!(
107 "{:.*}",
108 self.total.currency.precision as usize,
109 self.total.as_f64()
110 ),
111 )?;
112 dict.set_item(
113 "locked",
114 format!(
115 "{:.*}",
116 self.locked.currency.precision as usize,
117 self.locked.as_f64()
118 ),
119 )?;
120 dict.set_item(
121 "free",
122 format!(
123 "{:.*}",
124 self.free.currency.precision as usize,
125 self.free.as_f64()
126 ),
127 )?;
128 dict.set_item("currency", self.currency.code.to_string())?;
129 Ok(dict.into())
130 }
131}
132
133#[pymethods]
134#[pyo3_stub_gen::derive::gen_stub_pymethods]
135impl MarginBalance {
136 #[new]
147 #[pyo3(signature = (initial, maintenance, instrument_id=None))]
148 fn py_new(
149 initial: Money,
150 maintenance: Money,
151 instrument_id: Option<InstrumentId>,
152 ) -> PyResult<Self> {
153 Self::new_checked(initial, maintenance, instrument_id).map_err(to_pyvalue_err)
154 }
155
156 fn __repr__(&self) -> String {
157 format!("{self:?}")
158 }
159
160 fn __str__(&self) -> String {
161 self.to_string()
162 }
163
164 fn __hash__(&self) -> isize {
165 let mut h = DefaultHasher::new();
166 self.initial.raw.hash(&mut h);
167 self.maintenance.raw.hash(&mut h);
168 self.currency.code.hash(&mut h);
169 self.instrument_id.hash(&mut h);
170 h.finish() as isize
171 }
172
173 #[pyo3(name = "copy")]
175 fn py_copy(&self) -> Self {
176 *self
177 }
178
179 #[staticmethod]
185 #[pyo3(name = "from_dict")]
186 pub fn py_from_dict(values: &Bound<'_, PyDict>) -> PyResult<Self> {
187 let currency_str = get_required_string(values, "currency")?;
188 let initial_str = get_required_string(values, "initial")?;
189 let initial: f64 = initial_str.parse::<f64>().map_err(|e| {
190 to_pyvalue_err(format!(
191 "invalid MarginBalance initial '{initial_str}': {e}"
192 ))
193 })?;
194 let maintenance_str = get_required_string(values, "maintenance")?;
195 let maintenance: f64 = maintenance_str.parse::<f64>().map_err(|e| {
196 to_pyvalue_err(format!(
197 "invalid MarginBalance maintenance '{maintenance_str}': {e}"
198 ))
199 })?;
200 let instrument_id = get_optional_parsed(values, "instrument_id", |s| {
201 Ok::<InstrumentId, String>(InstrumentId::from(s))
202 })?;
203 let currency = Currency::from_str(currency_str.as_str()).map_err(to_pyvalue_err)?;
204 Self::new_checked(
205 Money::new(initial, currency),
206 Money::new(maintenance, currency),
207 instrument_id,
208 )
209 .map_err(to_pyvalue_err)
210 }
211
212 #[pyo3(name = "to_dict")]
219 pub fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
220 let dict = PyDict::new(py);
221 dict.set_item("type", stringify!(MarginBalance))?;
222 dict.set_item(
223 "initial",
224 format!(
225 "{:.*}",
226 self.initial.currency.precision as usize,
227 self.initial.as_f64()
228 ),
229 )?;
230 dict.set_item(
231 "maintenance",
232 format!(
233 "{:.*}",
234 self.maintenance.currency.precision as usize,
235 self.maintenance.as_f64()
236 ),
237 )?;
238 dict.set_item("currency", self.currency.code.to_string())?;
239 match self.instrument_id {
240 Some(id) => dict.set_item("instrument_id", id.to_string())?,
241 None => dict.set_item("instrument_id", py.None())?,
242 }
243 Ok(dict.into())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use pyo3::{Python, types::PyDict};
250 use rstest::rstest;
251
252 use super::*;
253
254 #[rstest]
255 #[case(
256 "total",
257 "ValueError: invalid AccountBalance total 'not-a-number': invalid float literal"
258 )]
259 #[case(
260 "free",
261 "ValueError: invalid AccountBalance free 'not-a-number': invalid float literal"
262 )]
263 #[case(
264 "locked",
265 "ValueError: invalid AccountBalance locked 'not-a-number': invalid float literal"
266 )]
267 fn test_account_balance_from_dict_rejects_invalid_numeric_field(
268 #[case] field: &str,
269 #[case] expected: &str,
270 ) {
271 Python::initialize();
272 Python::attach(|py| {
273 let values = PyDict::new(py);
274 values.set_item("currency", "USD").unwrap();
275 values.set_item("total", "1.00").unwrap();
276 values.set_item("free", "1.00").unwrap();
277 values.set_item("locked", "0.00").unwrap();
278 values.set_item(field, "not-a-number").unwrap();
279
280 let error = AccountBalance::py_from_dict(&values).unwrap_err();
281
282 assert_eq!(error.to_string(), expected);
283 });
284 }
285
286 #[rstest]
287 #[case(
288 "initial",
289 "ValueError: invalid MarginBalance initial 'not-a-number': invalid float literal"
290 )]
291 #[case(
292 "maintenance",
293 "ValueError: invalid MarginBalance maintenance 'not-a-number': invalid float literal"
294 )]
295 fn test_margin_balance_from_dict_rejects_invalid_numeric_field(
296 #[case] field: &str,
297 #[case] expected: &str,
298 ) {
299 Python::initialize();
300 Python::attach(|py| {
301 let values = PyDict::new(py);
302 values.set_item("currency", "USD").unwrap();
303 values.set_item("initial", "1.00").unwrap();
304 values.set_item("maintenance", "0.50").unwrap();
305 values.set_item("instrument_id", py.None()).unwrap();
306 values.set_item(field, "not-a-number").unwrap();
307
308 let error = MarginBalance::py_from_dict(&values).unwrap_err();
309
310 assert_eq!(error.to_string(), expected);
311 });
312 }
313}