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_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    /// 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    /// Returns a copy of this balance.
60    #[pyo3(name = "copy")]
61    fn py_copy(&self) -> Self {
62        *self
63    }
64
65    /// Constructs an [`AccountBalance`] from a Python dict.
66    ///
67    /// # Errors
68    ///
69    /// Returns a `PyErr` if parsing or conversion fails.
70    #[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    /// Converts this [`AccountBalance`] into a Python dict.
96    ///
97    /// # Errors
98    ///
99    /// Returns a `PyErr` if serialization fails.
100    #[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    /// Represents a margin balance.
137    ///
138    /// Margin entries have two mutually exclusive scopes:
139    ///
140    /// - Per-instrument: `instrument_id = Some(id)`. Used for isolated margin and
141    ///   for calculated margin in backtest mode where each instrument carries its
142    ///   own reserve.
143    /// - Account-wide (cross margin): `instrument_id = None`. Used for venues that
144    ///   report a single aggregate margin per collateral currency (most derivatives
145    ///   venues in cross-margin mode).
146    #[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    /// Returns a copy of this margin balance.
174    #[pyo3(name = "copy")]
175    fn py_copy(&self) -> Self {
176        *self
177    }
178
179    /// Constructs a [`MarginBalance`] from a Python dict.
180    ///
181    /// # Errors
182    ///
183    /// Returns a `PyErr` if parsing or conversion fails.
184    #[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    /// Converts this [`MarginBalance`] into a Python dict.
213    ///
214    /// # Errors
215    ///
216    /// Returns a `PyErr` if serialization fails.
217    ///
218    #[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}