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