Skip to main content

nautilus_model/python/instruments/
mod.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
16//! Instrument definitions the trading domain model.
17
18use nautilus_core::python::to_pyvalue_err;
19use pyo3::{
20    IntoPyObjectExt, Py, PyAny, PyResult, Python,
21    types::{PyAnyMethods, PyDict, PyDictMethods},
22};
23
24use crate::{
25    instruments::{
26        BettingInstrument, BinaryOption, Cfd, Commodity, CryptoFuture, CryptoPerpetual,
27        CurrencyPair, Equity, FuturesContract, FuturesSpread, IndexInstrument, InstrumentAny,
28        OptionContract, OptionSpread, PerpetualContract, TokenizedAsset,
29        crypto_option::CryptoOption,
30    },
31    types::{Currency, Money, Price, Quantity},
32};
33
34/// Pre-registers crypto currency codes from a dict prior to strict deserialization.
35///
36/// Crypto instrument roundtrips (e.g. `CryptoPerpetual.from_dict(...)`) can carry
37/// newly listed assets not present in the built-in currency map. Looking up each
38/// named field with [`Currency::get_or_create_crypto`] registers any unknown code
39/// as a crypto currency (precision 8), mirroring the non-strict Cython path.
40///
41/// Callers must only pass fields that are guaranteed to hold crypto assets (the
42/// underlying of a derivative); `quote_currency` and `settlement_currency` can
43/// legitimately be fiat (e.g. inverse perps on BitMEX quoted in USD) and must
44/// stay on the strict deserialization path.
45///
46/// Codes are trimmed before lookup; empty or whitespace-only values are skipped
47/// so downstream serde deserialization raises a normal `PyErr` instead of
48/// panicking in `Currency::new`.
49pub(crate) fn register_crypto_currencies_from_dict(
50    py: Python<'_>,
51    values: &Py<PyDict>,
52    fields: &[&str],
53) {
54    let dict = values.bind(py);
55    for field in fields {
56        if let Ok(Some(value)) = dict.get_item(field)
57            && let Ok(code) = value.extract::<String>()
58        {
59            let trimmed = code.trim();
60            if !trimmed.is_empty() {
61                let _ = Currency::get_or_create_crypto(trimmed);
62            }
63        }
64    }
65}
66
67macro_rules! impl_instrument_common_pymethods {
68    ($type:ty) => {
69        #[pyo3::pymethods]
70        impl $type {
71            fn __repr__(&self) -> String {
72                use crate::instruments::Instrument;
73                format!(
74                    "{}(id={}, price_precision={}, size_precision={})",
75                    stringify!($type),
76                    self.id(),
77                    self.price_precision(),
78                    self.size_precision(),
79                )
80            }
81
82            /// Returns a price rounded to the instruments price precision.
83            #[pyo3(name = "make_price")]
84            fn py_make_price(&self, value: f64) -> pyo3::PyResult<Price> {
85                use crate::instruments::Instrument;
86                self.try_make_price(value)
87                    .map_err(nautilus_core::python::to_pyvalue_err)
88            }
89
90            /// Returns a quantity rounded to the instruments size precision.
91            #[pyo3(name = "make_qty")]
92            #[pyo3(signature = (value, round_down=false))]
93            fn py_make_qty(&self, value: f64, round_down: bool) -> pyo3::PyResult<Quantity> {
94                use crate::instruments::Instrument;
95                self.try_make_qty(value, Some(round_down))
96                    .map_err(nautilus_core::python::to_pyvalue_err)
97            }
98
99            /// Calculates the notional value from the given quantity and price.
100            #[pyo3(name = "notional_value")]
101            #[pyo3(signature = (quantity, price, use_quote_for_inverse=false))]
102            fn py_notional_value(
103                &self,
104                quantity: Quantity,
105                price: Price,
106                use_quote_for_inverse: bool,
107            ) -> Money {
108                use crate::instruments::Instrument;
109                self.calculate_notional_value(quantity, price, Some(use_quote_for_inverse))
110            }
111        }
112    };
113}
114
115impl_instrument_common_pymethods!(BettingInstrument);
116impl_instrument_common_pymethods!(BinaryOption);
117impl_instrument_common_pymethods!(Cfd);
118impl_instrument_common_pymethods!(Commodity);
119impl_instrument_common_pymethods!(CryptoFuture);
120impl_instrument_common_pymethods!(CryptoOption);
121impl_instrument_common_pymethods!(CryptoPerpetual);
122impl_instrument_common_pymethods!(CurrencyPair);
123impl_instrument_common_pymethods!(Equity);
124impl_instrument_common_pymethods!(FuturesContract);
125impl_instrument_common_pymethods!(FuturesSpread);
126impl_instrument_common_pymethods!(IndexInstrument);
127impl_instrument_common_pymethods!(OptionContract);
128impl_instrument_common_pymethods!(OptionSpread);
129impl_instrument_common_pymethods!(PerpetualContract);
130impl_instrument_common_pymethods!(TokenizedAsset);
131
132pub mod betting;
133pub mod binary_option;
134pub mod cfd;
135pub mod commodity;
136pub mod crypto_future;
137pub mod crypto_option;
138pub mod crypto_perpetual;
139pub mod currency_pair;
140pub mod equity;
141pub mod futures_contract;
142pub mod futures_spread;
143pub mod index_instrument;
144pub mod option_contract;
145pub mod option_spread;
146pub mod perpetual_contract;
147pub mod synthetic;
148pub mod tokenized_asset;
149
150/// Converts an [`InstrumentAny`] into a Python object.
151///
152/// # Errors
153///
154/// Returns a `PyErr` if conversion to a Python object fails.
155pub fn instrument_any_to_pyobject(py: Python, instrument: InstrumentAny) -> PyResult<Py<PyAny>> {
156    match instrument {
157        InstrumentAny::Betting(inst) => inst.into_py_any(py),
158        InstrumentAny::BinaryOption(inst) => inst.into_py_any(py),
159        InstrumentAny::Cfd(inst) => inst.into_py_any(py),
160        InstrumentAny::Commodity(inst) => inst.into_py_any(py),
161        InstrumentAny::CryptoFuture(inst) => inst.into_py_any(py),
162        InstrumentAny::CryptoOption(inst) => inst.into_py_any(py),
163        InstrumentAny::CryptoPerpetual(inst) => inst.into_py_any(py),
164        InstrumentAny::CurrencyPair(inst) => inst.into_py_any(py),
165        InstrumentAny::Equity(inst) => inst.into_py_any(py),
166        InstrumentAny::FuturesContract(inst) => inst.into_py_any(py),
167        InstrumentAny::FuturesSpread(inst) => inst.into_py_any(py),
168        InstrumentAny::IndexInstrument(inst) => inst.into_py_any(py),
169        InstrumentAny::OptionContract(inst) => inst.into_py_any(py),
170        InstrumentAny::OptionSpread(inst) => inst.into_py_any(py),
171        InstrumentAny::PerpetualContract(inst) => inst.into_py_any(py),
172        InstrumentAny::TokenizedAsset(inst) => inst.into_py_any(py),
173    }
174}
175
176/// Converts a Python object into an [`InstrumentAny`] enum.
177///
178/// # Errors
179///
180/// Returns a `PyErr` if extraction fails or the instrument type is unsupported.
181#[expect(clippy::needless_pass_by_value)]
182pub fn pyobject_to_instrument_any(py: Python, instrument: Py<PyAny>) -> PyResult<InstrumentAny> {
183    match instrument.getattr(py, "type_name")?.extract::<&str>(py)? {
184        stringify!(BettingInstrument) => Ok(InstrumentAny::Betting(
185            instrument.extract::<BettingInstrument>(py)?,
186        )),
187        stringify!(BinaryOption) => Ok(InstrumentAny::BinaryOption(
188            instrument.extract::<BinaryOption>(py)?,
189        )),
190        stringify!(Cfd) => Ok(InstrumentAny::Cfd(instrument.extract::<Cfd>(py)?)),
191        stringify!(Commodity) => Ok(InstrumentAny::Commodity(
192            instrument.extract::<Commodity>(py)?,
193        )),
194        stringify!(CryptoFuture) => Ok(InstrumentAny::CryptoFuture(
195            instrument.extract::<CryptoFuture>(py)?,
196        )),
197        stringify!(CryptoOption) => Ok(InstrumentAny::CryptoOption(
198            instrument.extract::<CryptoOption>(py)?,
199        )),
200        stringify!(CryptoPerpetual) => Ok(InstrumentAny::CryptoPerpetual(
201            instrument.extract::<CryptoPerpetual>(py)?,
202        )),
203        stringify!(CurrencyPair) => Ok(InstrumentAny::CurrencyPair(
204            instrument.extract::<CurrencyPair>(py)?,
205        )),
206        stringify!(Equity) => Ok(InstrumentAny::Equity(instrument.extract::<Equity>(py)?)),
207        stringify!(FuturesContract) => Ok(InstrumentAny::FuturesContract(
208            instrument.extract::<FuturesContract>(py)?,
209        )),
210        stringify!(FuturesSpread) => Ok(InstrumentAny::FuturesSpread(
211            instrument.extract::<FuturesSpread>(py)?,
212        )),
213        stringify!(IndexInstrument) => Ok(InstrumentAny::IndexInstrument(
214            instrument.extract::<IndexInstrument>(py)?,
215        )),
216        stringify!(OptionContract) => Ok(InstrumentAny::OptionContract(
217            instrument.extract::<OptionContract>(py)?,
218        )),
219        stringify!(OptionSpread) => Ok(InstrumentAny::OptionSpread(
220            instrument.extract::<OptionSpread>(py)?,
221        )),
222        stringify!(PerpetualContract) => Ok(InstrumentAny::PerpetualContract(
223            instrument.extract::<PerpetualContract>(py)?,
224        )),
225        stringify!(TokenizedAsset) => Ok(InstrumentAny::TokenizedAsset(
226            instrument.extract::<TokenizedAsset>(py)?,
227        )),
228        _ => Err(to_pyvalue_err(
229            "Error in conversion from `Py<PyAny>` to `InstrumentAny`",
230        )),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use pyo3::{prelude::*, types::PyDict};
237    use rstest::rstest;
238
239    use super::register_crypto_currencies_from_dict;
240    use crate::{enums::CurrencyType, types::Currency};
241
242    #[rstest]
243    fn test_register_crypto_currencies_from_dict_unknown_code() {
244        Python::initialize();
245        Python::attach(|py| {
246            let dict = PyDict::new(py);
247            dict.set_item("base_currency", "NEWHLP1").unwrap();
248            let values: Py<PyDict> = dict.unbind();
249
250            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
251
252            let created = Currency::try_from_str("NEWHLP1").unwrap();
253            assert_eq!(created.precision, 8);
254            assert_eq!(created.currency_type, CurrencyType::Crypto);
255        });
256    }
257
258    #[rstest]
259    fn test_register_crypto_currencies_from_dict_known_code_not_overwritten() {
260        Python::initialize();
261        Python::attach(|py| {
262            let dict = PyDict::new(py);
263            dict.set_item("quote_currency", "USD").unwrap();
264            let values: Py<PyDict> = dict.unbind();
265
266            register_crypto_currencies_from_dict(py, &values, &["quote_currency"]);
267
268            let usd = Currency::try_from_str("USD").unwrap();
269            assert_eq!(usd.precision, 2);
270            assert_eq!(usd.currency_type, CurrencyType::Fiat);
271        });
272    }
273
274    #[rstest]
275    fn test_register_crypto_currencies_from_dict_missing_key() {
276        Python::initialize();
277        Python::attach(|py| {
278            let dict = PyDict::new(py);
279            let values: Py<PyDict> = dict.unbind();
280
281            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
282
283            assert!(Currency::try_from_str("base_currency").is_none());
284        });
285    }
286
287    #[rstest]
288    fn test_register_crypto_currencies_from_dict_non_string_value() {
289        Python::initialize();
290        Python::attach(|py| {
291            let dict = PyDict::new(py);
292            dict.set_item("base_currency", 42).unwrap();
293            let values: Py<PyDict> = dict.unbind();
294
295            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
296
297            assert!(Currency::try_from_str("42").is_none());
298        });
299    }
300
301    #[rstest]
302    fn test_register_crypto_currencies_from_dict_trims_padding() {
303        // Whitespace-padded codes must be trimmed before registration so the
304        // global map doesn't accumulate `" BTC "`-style garbage entries.
305        Python::initialize();
306        Python::attach(|py| {
307            let dict = PyDict::new(py);
308            dict.set_item("base_currency", "  NEWHLP2  ").unwrap();
309            let values: Py<PyDict> = dict.unbind();
310
311            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
312
313            assert!(Currency::try_from_str("NEWHLP2").is_some());
314            assert!(Currency::try_from_str("  NEWHLP2  ").is_none());
315        });
316    }
317
318    #[rstest]
319    fn test_register_crypto_currencies_from_dict_blank_code_skipped() {
320        // Blank or whitespace-only codes must be skipped so strict deserialize produces
321        // a normal PyErr, not a panic from `Currency::new` via get_or_create_crypto.
322        Python::initialize();
323        Python::attach(|py| {
324            let dict = PyDict::new(py);
325            dict.set_item("base_currency", "").unwrap();
326            dict.set_item("quote_currency", "   ").unwrap();
327            let values: Py<PyDict> = dict.unbind();
328
329            register_crypto_currencies_from_dict(py, &values, &["base_currency", "quote_currency"]);
330
331            assert!(Currency::try_from_str("").is_none());
332            assert!(Currency::try_from_str("   ").is_none());
333        });
334    }
335}