Skip to main content

nautilus_model/python/types/
balance.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
16use 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    /// Represents an account balance denominated in a particular currency.
37    #[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    /// Returns a copy of this balance.
84    #[pyo3(name = "copy")]
85    fn py_copy(&self) -> Self {
86        *self
87    }
88
89    /// Constructs an [`AccountBalance`] from a Python dict.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if parsing or conversion fails.
94    #[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    /// Converts this [`AccountBalance`] into a Python dict.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if serialization fails.
110    #[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    /// Represents a margin balance.
126    ///
127    /// Margin entries have two mutually exclusive scopes:
128    ///
129    /// - Per-instrument: `instrument_id = Some(id)`. Used for isolated margin and
130    ///   for calculated margin in backtest mode where each instrument carries its
131    ///   own reserve.
132    /// - Account-wide (cross margin): `instrument_id = None`. Used for venues that
133    ///   report a single aggregate margin per collateral currency (most derivatives
134    ///   venues in cross-margin mode).
135    #[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    /// Returns a copy of this margin balance.
187    #[pyo3(name = "copy")]
188    fn py_copy(&self) -> Self {
189        *self
190    }
191
192    /// Constructs a [`MarginBalance`] from a Python dict.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if parsing or conversion fails.
197    #[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    /// Converts this [`MarginBalance`] into a Python dict.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if serialization fails.
217    #[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}