nautilus_model/python/
common.rs

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