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, CryptoFuturesSpread,
27        CryptoOptionSpread, CryptoPerpetual, CurrencyPair, Equity, FuturesContract, FuturesSpread,
28        IndexInstrument, InstrumentAny, OptionContract, OptionSpread, PerpetualContract,
29        TokenizedAsset, 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!(CryptoFuturesSpread);
121impl_instrument_common_pymethods!(CryptoOption);
122impl_instrument_common_pymethods!(CryptoOptionSpread);
123impl_instrument_common_pymethods!(CryptoPerpetual);
124impl_instrument_common_pymethods!(CurrencyPair);
125impl_instrument_common_pymethods!(Equity);
126impl_instrument_common_pymethods!(FuturesContract);
127impl_instrument_common_pymethods!(FuturesSpread);
128impl_instrument_common_pymethods!(IndexInstrument);
129impl_instrument_common_pymethods!(OptionContract);
130impl_instrument_common_pymethods!(OptionSpread);
131impl_instrument_common_pymethods!(PerpetualContract);
132impl_instrument_common_pymethods!(TokenizedAsset);
133
134pub mod betting;
135pub mod binary_option;
136pub mod cfd;
137pub mod commodity;
138pub mod crypto_future;
139pub mod crypto_futures_spread;
140pub mod crypto_option;
141pub mod crypto_option_spread;
142pub mod crypto_perpetual;
143pub mod currency_pair;
144pub mod equity;
145pub mod futures_contract;
146pub mod futures_spread;
147pub mod index_instrument;
148pub mod option_contract;
149pub mod option_spread;
150pub mod perpetual_contract;
151pub mod synthetic;
152pub mod tokenized_asset;
153
154/// Converts an [`InstrumentAny`] into a Python object.
155///
156/// # Errors
157///
158/// Returns a `PyErr` if conversion to a Python object fails.
159pub fn instrument_any_to_pyobject(py: Python, instrument: InstrumentAny) -> PyResult<Py<PyAny>> {
160    match instrument {
161        InstrumentAny::Betting(inst) => inst.into_py_any(py),
162        InstrumentAny::BinaryOption(inst) => inst.into_py_any(py),
163        InstrumentAny::Cfd(inst) => inst.into_py_any(py),
164        InstrumentAny::Commodity(inst) => inst.into_py_any(py),
165        InstrumentAny::CryptoFuture(inst) => inst.into_py_any(py),
166        InstrumentAny::CryptoFuturesSpread(inst) => inst.into_py_any(py),
167        InstrumentAny::CryptoOption(inst) => inst.into_py_any(py),
168        InstrumentAny::CryptoOptionSpread(inst) => inst.into_py_any(py),
169        InstrumentAny::CryptoPerpetual(inst) => inst.into_py_any(py),
170        InstrumentAny::CurrencyPair(inst) => inst.into_py_any(py),
171        InstrumentAny::Equity(inst) => inst.into_py_any(py),
172        InstrumentAny::FuturesContract(inst) => inst.into_py_any(py),
173        InstrumentAny::FuturesSpread(inst) => inst.into_py_any(py),
174        InstrumentAny::IndexInstrument(inst) => inst.into_py_any(py),
175        InstrumentAny::OptionContract(inst) => inst.into_py_any(py),
176        InstrumentAny::OptionSpread(inst) => inst.into_py_any(py),
177        InstrumentAny::PerpetualContract(inst) => inst.into_py_any(py),
178        InstrumentAny::TokenizedAsset(inst) => inst.into_py_any(py),
179    }
180}
181
182/// Converts a Python object into an [`InstrumentAny`] enum.
183///
184/// # Errors
185///
186/// Returns a `PyErr` if extraction fails or the instrument type is unsupported.
187#[expect(clippy::needless_pass_by_value)]
188pub fn pyobject_to_instrument_any(py: Python, instrument: Py<PyAny>) -> PyResult<InstrumentAny> {
189    match instrument.getattr(py, "type_name")?.extract::<&str>(py)? {
190        stringify!(BettingInstrument) => Ok(InstrumentAny::Betting(
191            instrument.extract::<BettingInstrument>(py)?,
192        )),
193        stringify!(BinaryOption) => Ok(InstrumentAny::BinaryOption(
194            instrument.extract::<BinaryOption>(py)?,
195        )),
196        stringify!(Cfd) => Ok(InstrumentAny::Cfd(instrument.extract::<Cfd>(py)?)),
197        stringify!(Commodity) => Ok(InstrumentAny::Commodity(
198            instrument.extract::<Commodity>(py)?,
199        )),
200        stringify!(CryptoFuture) => Ok(InstrumentAny::CryptoFuture(
201            instrument.extract::<CryptoFuture>(py)?,
202        )),
203        stringify!(CryptoFuturesSpread) => Ok(InstrumentAny::CryptoFuturesSpread(
204            instrument.extract::<CryptoFuturesSpread>(py)?,
205        )),
206        stringify!(CryptoOption) => Ok(InstrumentAny::CryptoOption(
207            instrument.extract::<CryptoOption>(py)?,
208        )),
209        stringify!(CryptoOptionSpread) => Ok(InstrumentAny::CryptoOptionSpread(
210            instrument.extract::<CryptoOptionSpread>(py)?,
211        )),
212        stringify!(CryptoPerpetual) => Ok(InstrumentAny::CryptoPerpetual(
213            instrument.extract::<CryptoPerpetual>(py)?,
214        )),
215        stringify!(CurrencyPair) => Ok(InstrumentAny::CurrencyPair(
216            instrument.extract::<CurrencyPair>(py)?,
217        )),
218        stringify!(Equity) => Ok(InstrumentAny::Equity(instrument.extract::<Equity>(py)?)),
219        stringify!(FuturesContract) => Ok(InstrumentAny::FuturesContract(
220            instrument.extract::<FuturesContract>(py)?,
221        )),
222        stringify!(FuturesSpread) => Ok(InstrumentAny::FuturesSpread(
223            instrument.extract::<FuturesSpread>(py)?,
224        )),
225        stringify!(IndexInstrument) => Ok(InstrumentAny::IndexInstrument(
226            instrument.extract::<IndexInstrument>(py)?,
227        )),
228        stringify!(OptionContract) => Ok(InstrumentAny::OptionContract(
229            instrument.extract::<OptionContract>(py)?,
230        )),
231        stringify!(OptionSpread) => Ok(InstrumentAny::OptionSpread(
232            instrument.extract::<OptionSpread>(py)?,
233        )),
234        stringify!(PerpetualContract) => Ok(InstrumentAny::PerpetualContract(
235            instrument.extract::<PerpetualContract>(py)?,
236        )),
237        stringify!(TokenizedAsset) => Ok(InstrumentAny::TokenizedAsset(
238            instrument.extract::<TokenizedAsset>(py)?,
239        )),
240        _ => Err(to_pyvalue_err(
241            "Error in conversion from `Py<PyAny>` to `InstrumentAny`",
242        )),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use pyo3::{prelude::*, types::PyDict};
249    use rstest::rstest;
250
251    use super::register_crypto_currencies_from_dict;
252    use crate::{enums::CurrencyType, types::Currency};
253
254    #[rstest]
255    fn test_register_crypto_currencies_from_dict_unknown_code() {
256        Python::initialize();
257        Python::attach(|py| {
258            let dict = PyDict::new(py);
259            dict.set_item("base_currency", "NEWHLP1").unwrap();
260            let values: Py<PyDict> = dict.unbind();
261
262            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
263
264            let created = Currency::try_from_str("NEWHLP1").unwrap();
265            assert_eq!(created.precision, 8);
266            assert_eq!(created.currency_type, CurrencyType::Crypto);
267        });
268    }
269
270    #[rstest]
271    fn test_register_crypto_currencies_from_dict_known_code_not_overwritten() {
272        Python::initialize();
273        Python::attach(|py| {
274            let dict = PyDict::new(py);
275            dict.set_item("quote_currency", "USD").unwrap();
276            let values: Py<PyDict> = dict.unbind();
277
278            register_crypto_currencies_from_dict(py, &values, &["quote_currency"]);
279
280            let usd = Currency::try_from_str("USD").unwrap();
281            assert_eq!(usd.precision, 2);
282            assert_eq!(usd.currency_type, CurrencyType::Fiat);
283        });
284    }
285
286    #[rstest]
287    fn test_register_crypto_currencies_from_dict_missing_key() {
288        Python::initialize();
289        Python::attach(|py| {
290            let dict = PyDict::new(py);
291            let values: Py<PyDict> = dict.unbind();
292
293            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
294
295            assert!(Currency::try_from_str("base_currency").is_none());
296        });
297    }
298
299    #[rstest]
300    fn test_register_crypto_currencies_from_dict_non_string_value() {
301        Python::initialize();
302        Python::attach(|py| {
303            let dict = PyDict::new(py);
304            dict.set_item("base_currency", 42).unwrap();
305            let values: Py<PyDict> = dict.unbind();
306
307            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
308
309            assert!(Currency::try_from_str("42").is_none());
310        });
311    }
312
313    #[rstest]
314    fn test_register_crypto_currencies_from_dict_trims_padding() {
315        // Whitespace-padded codes must be trimmed before registration so the
316        // global map doesn't accumulate `" BTC "`-style garbage entries.
317        Python::initialize();
318        Python::attach(|py| {
319            let dict = PyDict::new(py);
320            dict.set_item("base_currency", "  NEWHLP2  ").unwrap();
321            let values: Py<PyDict> = dict.unbind();
322
323            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
324
325            assert!(Currency::try_from_str("NEWHLP2").is_some());
326            assert!(Currency::try_from_str("  NEWHLP2  ").is_none());
327        });
328    }
329
330    #[rstest]
331    fn test_register_crypto_currencies_from_dict_blank_code_skipped() {
332        // Blank or whitespace-only codes must be skipped so strict deserialize produces
333        // a normal PyErr, not a panic from `Currency::new` via get_or_create_crypto.
334        Python::initialize();
335        Python::attach(|py| {
336            let dict = PyDict::new(py);
337            dict.set_item("base_currency", "").unwrap();
338            dict.set_item("quote_currency", "   ").unwrap();
339            let values: Py<PyDict> = dict.unbind();
340
341            register_crypto_currencies_from_dict(py, &values, &["base_currency", "quote_currency"]);
342
343            assert!(Currency::try_from_str("").is_none());
344            assert!(Currency::try_from_str("   ").is_none());
345        });
346    }
347}