Skip to main content

nautilus_model/python/
common.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 indexmap::IndexMap;
17use nautilus_core::python::to_pyvalue_err;
18use pyo3::{
19    conversion::IntoPyObjectExt,
20    prelude::*,
21    types::{PyDict, PyList, PyNone},
22};
23use serde_json::Value;
24use strum::IntoEnumIterator;
25
26use crate::types::{Currency, Money};
27
28pub const PY_MODULE_MODEL: &str = "nautilus_trader.model";
29
30/// Python iterator over the variants of an enum.
31#[allow(missing_debug_implementations)]
32#[pyclass]
33#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")]
34pub struct EnumIterator {
35    // Type erasure for code reuse, generic types can't be exposed to Python
36    iter: Box<dyn Iterator<Item = PyResult<Py<PyAny>>> + Send + Sync>,
37}
38
39#[pymethods]
40#[pyo3_stub_gen::derive::gen_stub_pymethods]
41impl EnumIterator {
42    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
43        slf
44    }
45
46    fn __next__(mut slf: PyRefMut<'_, Self>) -> PyResult<Option<Py<PyAny>>> {
47        slf.iter.next().transpose()
48    }
49}
50
51impl EnumIterator {
52    /// Creates a new Python iterator over the variants of an enum.
53    #[must_use]
54    pub fn new<'py, E>(py: Python<'py>) -> Self
55    where
56        E: strum::IntoEnumIterator + IntoPyObjectExt<'py>,
57        <E as IntoEnumIterator>::Iterator: Send,
58    {
59        Self {
60            iter: Box::new(
61                E::iter()
62                    .map(|var| var.into_py_any(py))
63                    // Force eager evaluation because `py` isn't `Send`
64                    .collect::<Vec<_>>()
65                    .into_iter(),
66            ),
67        }
68    }
69}
70
71/// Converts a JSON `Value::Object` into a Python `dict`.
72///
73/// # Errors
74///
75/// Returns a `PyErr` if:
76/// - the input `val` is not a JSON object.
77/// - conversion of any nested JSON value into a Python object fails.
78pub fn value_to_pydict(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
79    let dict = PyDict::new(py);
80
81    match val {
82        Value::Object(map) => {
83            for (key, value) in map {
84                let py_value = value_to_pyobject(py, value)?;
85                dict.set_item(key, py_value)?;
86            }
87        }
88        // This shouldn't be reached in this function, but we include it for completeness
89        _ => return Err(to_pyvalue_err("Expected JSON object")),
90    }
91
92    dict.into_py_any(py)
93}
94
95/// Converts a JSON `Value` into a corresponding Python object.
96///
97/// # Errors
98///
99/// Returns a `PyErr` if:
100/// - numeric extraction fails.
101/// - encountering an unsupported JSON number type.
102/// - conversion of nested arrays or objects fails.
103pub fn value_to_pyobject(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
104    match val {
105        Value::Null => Ok(py.None()),
106        Value::Bool(b) => b.into_py_any(py),
107        Value::String(s) => s.into_py_any(py),
108        Value::Number(n) => {
109            if n.is_i64() {
110                n.as_i64()
111                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as i64"))?
112                    .into_py_any(py)
113            } else if n.is_u64() {
114                n.as_u64()
115                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as u64"))?
116                    .into_py_any(py)
117            } else if n.is_f64() {
118                n.as_f64()
119                    .ok_or_else(|| to_pyvalue_err("JSON number could not be read as f64"))?
120                    .into_py_any(py)
121            } else {
122                Err(to_pyvalue_err("Unsupported JSON number type"))
123            }
124        }
125        Value::Array(arr) => {
126            let py_list = PyList::new(py, &[] as &[Py<PyAny>])?;
127            for item in arr {
128                let py_item = value_to_pyobject(py, item)?;
129                py_list.append(py_item)?;
130            }
131            py_list.into_py_any(py)
132        }
133        Value::Object(_) => value_to_pydict(py, val),
134    }
135}
136
137// Re-export centralized Params conversion functions from nautilus_core
138// Backward compatibility: re-export pydict_to_params as an alias
139pub use nautilus_core::{
140    from_pydict as pydict_to_params, from_pydict, python::params::params_to_pydict,
141};
142
143/// Converts a list of `Money` values into a Python list of strings, or `None` if empty.
144///
145/// # Errors
146///
147/// Returns a `PyErr` if Python list creation or conversion fails.
148pub fn commissions_from_vec(py: Python<'_>, commissions: Vec<Money>) -> PyResult<Bound<'_, PyAny>> {
149    let mut values = Vec::new();
150
151    for value in commissions {
152        values.push(value.to_string());
153    }
154
155    if values.is_empty() {
156        Ok(PyNone::get(py).to_owned().into_any())
157    } else {
158        values.sort();
159        Ok(PyList::new(py, &values)?.into_any())
160    }
161}
162
163/// Converts an `IndexMap<Currency, Money>` into a Python list of strings, or `None` if empty.
164///
165/// # Errors
166///
167/// Returns a `PyErr` if Python list creation or conversion fails.
168pub fn commissions_from_indexmap<'py>(
169    py: Python<'py>,
170    commissions: &IndexMap<Currency, Money>,
171) -> PyResult<Bound<'py, PyAny>> {
172    commissions_from_vec(py, commissions.values().copied().collect())
173}
174
175#[cfg(test)]
176mod tests {
177    use pyo3::types::{PyBool, PyInt, PyString};
178    use rstest::rstest;
179    use serde_json::{Value, json};
180
181    use super::*;
182
183    #[derive(Debug, Clone, Copy)]
184    enum ExpectedNumber {
185        I64(i64),
186        U64(u64),
187        F64(f64),
188    }
189
190    #[rstest]
191    fn test_value_to_pydict() {
192        Python::initialize();
193        Python::attach(|py| {
194            let json_str = r#"
195        {
196            "type": "OrderAccepted",
197            "ts_event": 42,
198            "is_reconciliation": false
199        }
200        "#;
201
202            let val: Value = serde_json::from_str(json_str).unwrap();
203            let py_dict_ref = value_to_pydict(py, &val).unwrap();
204            let py_dict = py_dict_ref.bind(py);
205
206            assert_eq!(
207                py_dict
208                    .get_item("type")
209                    .unwrap()
210                    .cast::<PyString>()
211                    .unwrap()
212                    .to_str()
213                    .unwrap(),
214                "OrderAccepted"
215            );
216            assert_eq!(
217                py_dict
218                    .get_item("ts_event")
219                    .unwrap()
220                    .cast::<PyInt>()
221                    .unwrap()
222                    .extract::<i64>()
223                    .unwrap(),
224                42
225            );
226            assert!(
227                !py_dict
228                    .get_item("is_reconciliation")
229                    .unwrap()
230                    .cast::<PyBool>()
231                    .unwrap()
232                    .is_true()
233            );
234        });
235    }
236
237    #[rstest]
238    #[case(json!(-100_i64), ExpectedNumber::I64(-100))]
239    #[case(json!(42_u64), ExpectedNumber::U64(42))]
240    #[case(json!(2.5_f64), ExpectedNumber::F64(2.5))]
241    fn test_value_to_pyobject_number_branches(
242        #[case] value: Value,
243        #[case] expected: ExpectedNumber,
244    ) {
245        Python::initialize();
246        Python::attach(|py| {
247            let py_obj = value_to_pyobject(py, &value).unwrap();
248
249            match expected {
250                ExpectedNumber::I64(expected) => {
251                    assert_eq!(py_obj.extract::<i64>(py).unwrap(), expected);
252                }
253                ExpectedNumber::U64(expected) => {
254                    assert_eq!(py_obj.extract::<u64>(py).unwrap(), expected);
255                }
256                ExpectedNumber::F64(expected) => {
257                    let actual = py_obj.extract::<f64>(py).unwrap();
258                    assert!((actual - expected).abs() < f64::EPSILON);
259                }
260            }
261        });
262    }
263
264    #[rstest]
265    fn test_value_to_pyobject_string() {
266        Python::initialize();
267        Python::attach(|py| {
268            let val = Value::String("Hello, world!".to_string());
269            let py_obj = value_to_pyobject(py, &val).unwrap();
270
271            assert_eq!(py_obj.extract::<&str>(py).unwrap(), "Hello, world!");
272        });
273    }
274
275    #[rstest]
276    fn test_value_to_pyobject_bool() {
277        Python::initialize();
278        Python::attach(|py| {
279            let val = Value::Bool(true);
280            let py_obj = value_to_pyobject(py, &val).unwrap();
281
282            assert!(py_obj.extract::<bool>(py).unwrap());
283        });
284    }
285
286    #[rstest]
287    fn test_value_to_pyobject_array() {
288        Python::initialize();
289        Python::attach(|py| {
290            let val = Value::Array(vec![
291                Value::String("item1".to_string()),
292                Value::String("item2".to_string()),
293            ]);
294            let binding = value_to_pyobject(py, &val).unwrap();
295            let py_list: &Bound<'_, PyList> = binding.bind(py).cast::<PyList>().unwrap();
296
297            assert_eq!(py_list.len(), 2);
298            assert_eq!(
299                py_list.get_item(0).unwrap().extract::<&str>().unwrap(),
300                "item1"
301            );
302            assert_eq!(
303                py_list.get_item(1).unwrap().extract::<&str>().unwrap(),
304                "item2"
305            );
306        });
307    }
308
309    #[rstest]
310    fn test_commissions_from_vec_empty_returns_none() {
311        Python::initialize();
312        Python::attach(|py| {
313            let value = commissions_from_vec(py, vec![]).unwrap();
314
315            assert!(value.is_none());
316        });
317    }
318
319    #[rstest]
320    fn test_commissions_from_vec_returns_sorted_list() {
321        Python::initialize();
322        Python::attach(|py| {
323            let value =
324                commissions_from_vec(py, vec![Money::from("2.00 USD"), Money::from("1.00 USD")])
325                    .unwrap();
326            let py_list: &Bound<'_, PyList> = value.cast::<PyList>().unwrap();
327
328            assert_eq!(py_list.len(), 2);
329            assert_eq!(
330                py_list.get_item(0).unwrap().extract::<&str>().unwrap(),
331                "1.00 USD"
332            );
333            assert_eq!(
334                py_list.get_item(1).unwrap().extract::<&str>().unwrap(),
335                "2.00 USD"
336            );
337        });
338    }
339}