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::{IntoPyObjectNautilusExt, 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.core.nautilus_pyo3.model";
29
30/// Python iterator over the variants of an enum.
31#[allow(missing_debug_implementations)]
32#[pyclass]
33pub struct EnumIterator {
34    // Type erasure for code reuse, generic types can't be exposed to Python
35    iter: Box<dyn Iterator<Item = Py<PyAny>> + Send + Sync>,
36}
37
38#[pymethods]
39impl EnumIterator {
40    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
41        slf
42    }
43
44    fn __next__(mut slf: PyRefMut<'_, Self>) -> Option<Py<PyAny>> {
45        slf.iter.next()
46    }
47}
48
49impl EnumIterator {
50    /// Creates a new Python iterator over the variants of an enum.
51    ///
52    /// # Panics
53    ///
54    /// Panics if conversion of enum variants into Python objects fails.
55    #[must_use]
56    pub fn new<'py, E>(py: Python<'py>) -> Self
57    where
58        E: strum::IntoEnumIterator + IntoPyObjectExt<'py>,
59        <E as IntoEnumIterator>::Iterator: Send,
60    {
61        Self {
62            iter: Box::new(
63                E::iter()
64                    .map(|var| var.into_py_any_unwrap(py))
65                    // Force eager evaluation because `py` isn't `Send`
66                    .collect::<Vec<_>>()
67                    .into_iter(),
68            ),
69        }
70    }
71}
72
73/// Converts a JSON `Value::Object` into a Python `dict`.
74///
75/// # Errors
76///
77/// Returns a `PyErr` if:
78/// - the input `val` is not a JSON object.
79/// - conversion of any nested JSON value into a Python object fails.
80pub fn value_to_pydict(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
81    let dict = PyDict::new(py);
82
83    match val {
84        Value::Object(map) => {
85            for (key, value) in map {
86                let py_value = value_to_pyobject(py, value)?;
87                dict.set_item(key, py_value)?;
88            }
89        }
90        // This shouldn't be reached in this function, but we include it for completeness
91        _ => return Err(to_pyvalue_err("Expected JSON object")),
92    }
93
94    dict.into_py_any(py)
95}
96
97/// Converts a JSON `Value` into a corresponding Python object.
98///
99/// # Panics
100///
101/// Panics if parsing numbers (`as_i64`, `as_f64`) or creating the Python list (`PyList::new().expect`) fails.
102///
103/// # Errors
104///
105/// Returns a `PyErr` if:
106/// - encountering an unsupported JSON number type.
107/// - conversion of nested arrays or objects fails.
108pub fn value_to_pyobject(py: Python<'_>, val: &Value) -> PyResult<Py<PyAny>> {
109    match val {
110        Value::Null => Ok(py.None()),
111        Value::Bool(b) => b.into_py_any(py),
112        Value::String(s) => s.into_py_any(py),
113        Value::Number(n) => {
114            if n.is_i64() {
115                n.as_i64().unwrap().into_py_any(py)
116            } else if n.is_u64() {
117                n.as_u64().unwrap().into_py_any(py)
118            } else if n.is_f64() {
119                n.as_f64().unwrap().into_py_any(py)
120            } else {
121                Err(to_pyvalue_err("Unsupported JSON number type"))
122            }
123        }
124        Value::Array(arr) => {
125            let py_list =
126                PyList::new(py, &[] as &[Py<PyAny>]).expect("Invalid `ExactSizeIterator`");
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/// # Panics
146///
147/// Panics if creating the Python list fails or during the conversion unwrap.
148///
149/// # Errors
150///
151/// Returns a `PyErr` if Python list creation or conversion fails.
152pub fn commissions_from_vec(py: Python<'_>, commissions: Vec<Money>) -> PyResult<Bound<'_, PyAny>> {
153    let mut values = Vec::new();
154
155    for value in commissions {
156        values.push(value.to_string());
157    }
158
159    if values.is_empty() {
160        Ok(PyNone::get(py).to_owned().into_any())
161    } else {
162        values.sort();
163        Ok(PyList::new(py, &values)
164            .expect("ExactSizeIterator")
165            .into_any())
166    }
167}
168
169/// Converts an `IndexMap<Currency, Money>` into a Python list of strings, or `None` if empty.
170///
171/// # Errors
172///
173/// Returns a `PyErr` if Python list creation or conversion fails.
174pub fn commissions_from_indexmap(
175    py: Python<'_>,
176    commissions: IndexMap<Currency, Money>,
177) -> PyResult<Bound<'_, PyAny>> {
178    commissions_from_vec(py, commissions.values().copied().collect())
179}
180
181#[cfg(test)]
182mod tests {
183    use pyo3::{
184        prelude::*,
185        types::{PyBool, PyInt, PyString},
186    };
187    use rstest::rstest;
188    use serde_json::Value;
189
190    use super::*;
191
192    #[rstest]
193    fn test_value_to_pydict() {
194        Python::initialize();
195        Python::attach(|py| {
196            let json_str = r#"
197        {
198            "type": "OrderAccepted",
199            "ts_event": 42,
200            "is_reconciliation": false
201        }
202        "#;
203
204            let val: Value = serde_json::from_str(json_str).unwrap();
205            let py_dict_ref = value_to_pydict(py, &val).unwrap();
206            let py_dict = py_dict_ref.bind(py);
207
208            assert_eq!(
209                py_dict
210                    .get_item("type")
211                    .unwrap()
212                    .cast::<PyString>()
213                    .unwrap()
214                    .to_str()
215                    .unwrap(),
216                "OrderAccepted"
217            );
218            assert_eq!(
219                py_dict
220                    .get_item("ts_event")
221                    .unwrap()
222                    .cast::<PyInt>()
223                    .unwrap()
224                    .extract::<i64>()
225                    .unwrap(),
226                42
227            );
228            assert!(
229                !py_dict
230                    .get_item("is_reconciliation")
231                    .unwrap()
232                    .cast::<PyBool>()
233                    .unwrap()
234                    .is_true()
235            );
236        });
237    }
238
239    #[rstest]
240    fn test_value_to_pyobject_string() {
241        Python::initialize();
242        Python::attach(|py| {
243            let val = Value::String("Hello, world!".to_string());
244            let py_obj = value_to_pyobject(py, &val).unwrap();
245
246            assert_eq!(py_obj.extract::<&str>(py).unwrap(), "Hello, world!");
247        });
248    }
249
250    #[rstest]
251    fn test_value_to_pyobject_bool() {
252        Python::initialize();
253        Python::attach(|py| {
254            let val = Value::Bool(true);
255            let py_obj = value_to_pyobject(py, &val).unwrap();
256
257            assert!(py_obj.extract::<bool>(py).unwrap());
258        });
259    }
260
261    #[rstest]
262    fn test_value_to_pyobject_array() {
263        Python::initialize();
264        Python::attach(|py| {
265            let val = Value::Array(vec![
266                Value::String("item1".to_string()),
267                Value::String("item2".to_string()),
268            ]);
269            let binding = value_to_pyobject(py, &val).unwrap();
270            let py_list: &Bound<'_, PyList> = binding.bind(py).cast::<PyList>().unwrap();
271
272            assert_eq!(py_list.len(), 2);
273            assert_eq!(
274                py_list.get_item(0).unwrap().extract::<&str>().unwrap(),
275                "item1"
276            );
277            assert_eq!(
278                py_list.get_item(1).unwrap().extract::<&str>().unwrap(),
279                "item2"
280            );
281        });
282    }
283}