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::{serialization::from_dict_pyo3, to_pyvalue_err};
19use pyo3::{
20    IntoPyObjectExt, Py, PyAny, PyResult, Python,
21    types::{PyAnyMethods, PyDict, PyDictMethods},
22};
23use rust_decimal::Decimal;
24use serde::de::DeserializeOwned;
25
26use crate::{
27    enums::{AssetClass, InstrumentClass},
28    instruments::{
29        BettingInstrument, BinaryOption, Cfd, Commodity, CryptoFuture, CryptoFuturesSpread,
30        CryptoOptionSpread, CryptoPerpetual, CurrencyPair, Equity, FuturesContract, FuturesSpread,
31        IndexInstrument, Instrument, InstrumentAny, OptionContract, OptionSpread,
32        PerpetualContract, TokenizedAsset, crypto_option::CryptoOption,
33    },
34    types::{Currency, Money, Price, Quantity},
35};
36
37/// Pre-registers crypto currency codes from a dict prior to strict deserialization.
38///
39/// Crypto instrument roundtrips (e.g. `CryptoPerpetual.from_dict(...)`) can carry
40/// newly listed assets not present in the built-in currency map. Looking up each
41/// named field with [`Currency::get_or_create_crypto`] registers any unknown code
42/// as a crypto currency (precision 8) instead of failing the roundtrip.
43///
44/// Callers must only pass fields that are guaranteed to hold crypto assets (the
45/// underlying of a derivative); `quote_currency` and `settlement_currency` can
46/// legitimately be fiat (e.g. inverse perps on BitMEX quoted in USD) and must
47/// stay on the strict deserialization path.
48///
49/// Codes are trimmed before lookup; empty or whitespace-only values are skipped
50/// so downstream serde deserialization raises a normal `PyErr` instead of
51/// panicking in `Currency::new`.
52pub(crate) fn register_crypto_currencies_from_dict(
53    py: Python<'_>,
54    values: &Py<PyDict>,
55    fields: &[&str],
56) {
57    let dict = values.bind(py);
58    for field in fields {
59        if let Ok(Some(value)) = dict.get_item(field)
60            && let Ok(code) = value.extract::<String>()
61        {
62            let trimmed = code.trim();
63            if !trimmed.is_empty() {
64                let _ = Currency::get_or_create_crypto(trimmed);
65            }
66        }
67    }
68}
69
70pub(crate) fn tick_scheme_to_py(instrument: &impl Instrument) -> Option<String> {
71    instrument.tick_scheme().map(|name| name.to_string())
72}
73
74pub(crate) fn from_dict_instrument_pyo3<T>(py: Python<'_>, values: Py<PyDict>) -> PyResult<T>
75where
76    T: DeserializeOwned,
77{
78    let values = instrument_dict_with_tick_scheme_alias(py, values)?;
79    from_dict_pyo3(py, values)
80}
81
82fn instrument_dict_with_tick_scheme_alias(
83    py: Python<'_>,
84    values: Py<PyDict>,
85) -> PyResult<Py<PyDict>> {
86    let dict = values.bind(py);
87    if dict.contains("tick_scheme")? || !dict.contains("tick_scheme_name")? {
88        return Ok(values);
89    }
90
91    let dict = dict.copy()?;
92    if let Some(value) = dict.get_item("tick_scheme_name")? {
93        dict.set_item("tick_scheme", value)?;
94    }
95    Ok(dict.unbind())
96}
97
98macro_rules! impl_instrument_common_pymethods {
99    ($type:ty) => {
100        #[pyo3_stub_gen::derive::gen_stub_pymethods]
101        #[pyo3::pymethods]
102        impl $type {
103            fn __repr__(&self) -> String {
104                format!(
105                    "{}(id={}, price_precision={}, size_precision={})",
106                    stringify!($type),
107                    self.id(),
108                    self.price_precision(),
109                    self.size_precision(),
110                )
111            }
112
113            #[getter]
114            #[pyo3(name = "tick_scheme")]
115            fn py_tick_scheme(&self) -> Option<String> {
116                self.tick_scheme().map(|name| name.to_string())
117            }
118
119            /// Returns the price `num_ticks` bid ticks away from value.
120            #[pyo3(name = "next_bid_price")]
121            #[pyo3(signature = (value, num_ticks=0))]
122            fn py_next_bid_price(&self, value: f64, num_ticks: i32) -> Option<Price> {
123                self.next_bid_price(value, num_ticks)
124            }
125
126            /// Returns the price `num_ticks` ask ticks away from value.
127            #[pyo3(name = "next_ask_price")]
128            #[pyo3(signature = (value, num_ticks=0))]
129            fn py_next_ask_price(&self, value: f64, num_ticks: i32) -> Option<Price> {
130                self.next_ask_price(value, num_ticks)
131            }
132
133            /// Returns prices up to `num_ticks` bid ticks away from value.
134            #[pyo3(name = "next_bid_prices")]
135            #[pyo3(signature = (value, num_ticks=100))]
136            fn py_next_bid_prices(
137                &self,
138                value: f64,
139                num_ticks: usize,
140            ) -> Vec<rust_decimal::Decimal> {
141                self.next_bid_prices(value, num_ticks)
142                    .into_iter()
143                    .map(|price| price.as_decimal())
144                    .collect()
145            }
146
147            /// Returns prices up to `num_ticks` ask ticks away from value.
148            #[pyo3(name = "next_ask_prices")]
149            #[pyo3(signature = (value, num_ticks=100))]
150            fn py_next_ask_prices(
151                &self,
152                value: f64,
153                num_ticks: usize,
154            ) -> Vec<rust_decimal::Decimal> {
155                self.next_ask_prices(value, num_ticks)
156                    .into_iter()
157                    .map(|price| price.as_decimal())
158                    .collect()
159            }
160
161            /// Returns a price rounded to the instruments price precision.
162            #[pyo3(name = "make_price")]
163            fn py_make_price(&self, value: f64) -> PyResult<Price> {
164                self.try_make_price(value)
165                    .map_err(nautilus_core::python::to_pyvalue_err)
166            }
167
168            /// Returns a quantity rounded to the instruments size precision.
169            #[pyo3(name = "make_qty")]
170            #[pyo3(signature = (value, round_down=false))]
171            fn py_make_qty(&self, value: f64, round_down: bool) -> PyResult<Quantity> {
172                self.try_make_qty(value, Some(round_down))
173                    .map_err(nautilus_core::python::to_pyvalue_err)
174            }
175
176            /// Calculates the notional value from the given quantity and price.
177            #[pyo3(name = "notional_value")]
178            #[pyo3(signature = (quantity, price, use_quote_for_inverse=false))]
179            fn py_notional_value(
180                &self,
181                quantity: Quantity,
182                price: Price,
183                use_quote_for_inverse: bool,
184            ) -> PyResult<Money> {
185                self.try_calculate_notional_value(quantity, price, Some(use_quote_for_inverse))
186                    .map_err(nautilus_core::python::to_pyvalue_err)
187            }
188        }
189    };
190}
191
192macro_rules! impl_instrument_getter {
193    ($name:literal, $getter:ident, $return_type:ty, $method:ident, $($type:ty),+ $(,)?) => {
194        $(
195            #[pyo3_stub_gen::derive::gen_stub_pymethods]
196            #[pyo3::pymethods]
197            impl $type {
198                #[getter]
199                #[pyo3(name = $name)]
200                fn $getter(&self) -> $return_type {
201                    Instrument::$method(self)
202                }
203            }
204        )+
205    };
206}
207
208macro_rules! impl_instrument_isin_getter {
209    ($($type:ty),+ $(,)?) => {
210        $(
211            #[pyo3_stub_gen::derive::gen_stub_pymethods]
212            #[pyo3::pymethods]
213            impl $type {
214                #[getter]
215                #[pyo3(name = "isin")]
216                fn py_isin(&self) -> Option<String> {
217                    Instrument::isin(self).map(|value| value.to_string())
218                }
219            }
220        )+
221    };
222}
223
224impl_instrument_common_pymethods!(BettingInstrument);
225impl_instrument_common_pymethods!(BinaryOption);
226impl_instrument_common_pymethods!(Cfd);
227impl_instrument_common_pymethods!(Commodity);
228impl_instrument_common_pymethods!(CryptoFuture);
229impl_instrument_common_pymethods!(CryptoFuturesSpread);
230impl_instrument_common_pymethods!(CryptoOption);
231impl_instrument_common_pymethods!(CryptoOptionSpread);
232impl_instrument_common_pymethods!(CryptoPerpetual);
233impl_instrument_common_pymethods!(CurrencyPair);
234impl_instrument_common_pymethods!(Equity);
235impl_instrument_common_pymethods!(FuturesContract);
236impl_instrument_common_pymethods!(FuturesSpread);
237impl_instrument_common_pymethods!(IndexInstrument);
238impl_instrument_common_pymethods!(OptionContract);
239impl_instrument_common_pymethods!(OptionSpread);
240impl_instrument_common_pymethods!(PerpetualContract);
241impl_instrument_common_pymethods!(TokenizedAsset);
242
243impl_instrument_getter!(
244    "asset_class",
245    py_asset_class,
246    AssetClass,
247    asset_class,
248    CryptoFuture,
249    CryptoFuturesSpread,
250    CryptoOption,
251    CryptoOptionSpread,
252    CryptoPerpetual,
253    CurrencyPair,
254    Equity,
255    IndexInstrument,
256);
257impl_instrument_getter!(
258    "instrument_class",
259    py_instrument_class,
260    InstrumentClass,
261    instrument_class,
262    BinaryOption,
263    Cfd,
264    Commodity,
265    CryptoFuture,
266    CryptoFuturesSpread,
267    CryptoOption,
268    CryptoOptionSpread,
269    CryptoPerpetual,
270    CurrencyPair,
271    Equity,
272    FuturesContract,
273    FuturesSpread,
274    IndexInstrument,
275    OptionContract,
276    OptionSpread,
277    PerpetualContract,
278    TokenizedAsset,
279);
280impl_instrument_getter!(
281    "is_inverse",
282    py_is_inverse,
283    bool,
284    is_inverse,
285    BettingInstrument,
286    BinaryOption,
287    Cfd,
288    Commodity,
289    CurrencyPair,
290    Equity,
291    FuturesContract,
292    FuturesSpread,
293    IndexInstrument,
294    OptionContract,
295    OptionSpread,
296    TokenizedAsset,
297);
298impl_instrument_getter!(
299    "is_quanto",
300    py_is_quanto,
301    bool,
302    is_quanto,
303    BettingInstrument,
304    BinaryOption,
305    Cfd,
306    Commodity,
307    CryptoFuture,
308    CryptoFuturesSpread,
309    CryptoOption,
310    CryptoOptionSpread,
311    CryptoPerpetual,
312    CurrencyPair,
313    Equity,
314    FuturesContract,
315    FuturesSpread,
316    IndexInstrument,
317    OptionContract,
318    OptionSpread,
319    PerpetualContract,
320    TokenizedAsset,
321);
322impl_instrument_isin_getter!(
323    BettingInstrument,
324    BinaryOption,
325    Cfd,
326    Commodity,
327    CryptoFuture,
328    CryptoFuturesSpread,
329    CryptoOption,
330    CryptoOptionSpread,
331    CryptoPerpetual,
332    CurrencyPair,
333    FuturesContract,
334    FuturesSpread,
335    IndexInstrument,
336    OptionContract,
337    OptionSpread,
338    PerpetualContract,
339);
340impl_instrument_getter!(
341    "lot_size",
342    py_lot_size,
343    Option<Quantity>,
344    lot_size,
345    BettingInstrument,
346    BinaryOption,
347    IndexInstrument,
348);
349impl_instrument_getter!(
350    "maker_fee",
351    py_maker_fee,
352    Decimal,
353    maker_fee,
354    IndexInstrument
355);
356impl_instrument_getter!(
357    "margin_init",
358    py_margin_init,
359    Decimal,
360    margin_init,
361    BettingInstrument,
362    IndexInstrument,
363);
364impl_instrument_getter!(
365    "margin_maint",
366    py_margin_maint,
367    Decimal,
368    margin_maint,
369    BettingInstrument,
370    IndexInstrument,
371);
372impl_instrument_getter!(
373    "max_notional",
374    py_max_notional,
375    Option<Money>,
376    max_notional,
377    Equity,
378    FuturesContract,
379    FuturesSpread,
380    IndexInstrument,
381    OptionContract,
382    OptionSpread,
383);
384impl_instrument_getter!(
385    "max_price",
386    py_max_price,
387    Option<Price>,
388    max_price,
389    IndexInstrument
390);
391impl_instrument_getter!(
392    "max_quantity",
393    py_max_quantity,
394    Option<Quantity>,
395    max_quantity,
396    IndexInstrument,
397);
398impl_instrument_getter!(
399    "min_notional",
400    py_min_notional,
401    Option<Money>,
402    min_notional,
403    Equity,
404    FuturesContract,
405    FuturesSpread,
406    IndexInstrument,
407    OptionContract,
408    OptionSpread,
409);
410impl_instrument_getter!(
411    "min_price",
412    py_min_price,
413    Option<Price>,
414    min_price,
415    IndexInstrument
416);
417impl_instrument_getter!(
418    "min_quantity",
419    py_min_quantity,
420    Option<Quantity>,
421    min_quantity,
422    IndexInstrument,
423);
424impl_instrument_getter!(
425    "multiplier",
426    py_multiplier,
427    Quantity,
428    multiplier,
429    BettingInstrument,
430    BinaryOption,
431    Cfd,
432    Commodity,
433    Equity,
434    IndexInstrument,
435);
436impl_instrument_getter!(
437    "quote_currency",
438    py_quote_currency,
439    Currency,
440    quote_currency,
441    BettingInstrument,
442    BinaryOption,
443    FuturesContract,
444    FuturesSpread,
445    OptionContract,
446    OptionSpread,
447);
448impl_instrument_getter!(
449    "taker_fee",
450    py_taker_fee,
451    Decimal,
452    taker_fee,
453    IndexInstrument
454);
455
456pub mod betting;
457pub mod binary_option;
458pub mod cfd;
459pub mod commodity;
460pub mod crypto_future;
461pub mod crypto_futures_spread;
462pub mod crypto_option;
463pub mod crypto_option_spread;
464pub mod crypto_perpetual;
465pub mod currency_pair;
466pub mod equity;
467pub mod futures_contract;
468pub mod futures_spread;
469pub mod index_instrument;
470pub mod option_contract;
471pub mod option_spread;
472pub mod perpetual_contract;
473pub mod synthetic;
474pub mod tokenized_asset;
475
476/// Converts an [`InstrumentAny`] into a Python object.
477///
478/// # Errors
479///
480/// Returns a `PyErr` if conversion to a Python object fails.
481pub fn instrument_any_to_pyobject(py: Python, instrument: InstrumentAny) -> PyResult<Py<PyAny>> {
482    match instrument {
483        InstrumentAny::Betting(inst) => inst.into_py_any(py),
484        InstrumentAny::BinaryOption(inst) => inst.into_py_any(py),
485        InstrumentAny::Cfd(inst) => inst.into_py_any(py),
486        InstrumentAny::Commodity(inst) => inst.into_py_any(py),
487        InstrumentAny::CryptoFuture(inst) => inst.into_py_any(py),
488        InstrumentAny::CryptoFuturesSpread(inst) => inst.into_py_any(py),
489        InstrumentAny::CryptoOption(inst) => inst.into_py_any(py),
490        InstrumentAny::CryptoOptionSpread(inst) => inst.into_py_any(py),
491        InstrumentAny::CryptoPerpetual(inst) => inst.into_py_any(py),
492        InstrumentAny::CurrencyPair(inst) => inst.into_py_any(py),
493        InstrumentAny::Equity(inst) => inst.into_py_any(py),
494        InstrumentAny::FuturesContract(inst) => inst.into_py_any(py),
495        InstrumentAny::FuturesSpread(inst) => inst.into_py_any(py),
496        InstrumentAny::IndexInstrument(inst) => inst.into_py_any(py),
497        InstrumentAny::OptionContract(inst) => inst.into_py_any(py),
498        InstrumentAny::OptionSpread(inst) => inst.into_py_any(py),
499        InstrumentAny::PerpetualContract(inst) => inst.into_py_any(py),
500        InstrumentAny::TokenizedAsset(inst) => inst.into_py_any(py),
501    }
502}
503
504/// Converts a Python object into an [`InstrumentAny`] enum.
505///
506/// # Errors
507///
508/// Returns a `PyErr` if extraction fails or the instrument type is unsupported.
509#[expect(clippy::needless_pass_by_value)]
510pub fn pyobject_to_instrument_any(py: Python, instrument: Py<PyAny>) -> PyResult<InstrumentAny> {
511    match instrument.getattr(py, "type_name")?.extract::<&str>(py)? {
512        stringify!(BettingInstrument) => Ok(InstrumentAny::Betting(
513            instrument.extract::<BettingInstrument>(py)?,
514        )),
515        stringify!(BinaryOption) => Ok(InstrumentAny::BinaryOption(
516            instrument.extract::<BinaryOption>(py)?,
517        )),
518        stringify!(Cfd) => Ok(InstrumentAny::Cfd(instrument.extract::<Cfd>(py)?)),
519        stringify!(Commodity) => Ok(InstrumentAny::Commodity(
520            instrument.extract::<Commodity>(py)?,
521        )),
522        stringify!(CryptoFuture) => Ok(InstrumentAny::CryptoFuture(
523            instrument.extract::<CryptoFuture>(py)?,
524        )),
525        stringify!(CryptoFuturesSpread) => Ok(InstrumentAny::CryptoFuturesSpread(
526            instrument.extract::<CryptoFuturesSpread>(py)?,
527        )),
528        stringify!(CryptoOption) => Ok(InstrumentAny::CryptoOption(
529            instrument.extract::<CryptoOption>(py)?,
530        )),
531        stringify!(CryptoOptionSpread) => Ok(InstrumentAny::CryptoOptionSpread(
532            instrument.extract::<CryptoOptionSpread>(py)?,
533        )),
534        stringify!(CryptoPerpetual) => Ok(InstrumentAny::CryptoPerpetual(
535            instrument.extract::<CryptoPerpetual>(py)?,
536        )),
537        stringify!(CurrencyPair) => Ok(InstrumentAny::CurrencyPair(
538            instrument.extract::<CurrencyPair>(py)?,
539        )),
540        stringify!(Equity) => Ok(InstrumentAny::Equity(instrument.extract::<Equity>(py)?)),
541        stringify!(FuturesContract) => Ok(InstrumentAny::FuturesContract(
542            instrument.extract::<FuturesContract>(py)?,
543        )),
544        stringify!(FuturesSpread) => Ok(InstrumentAny::FuturesSpread(
545            instrument.extract::<FuturesSpread>(py)?,
546        )),
547        stringify!(IndexInstrument) => Ok(InstrumentAny::IndexInstrument(
548            instrument.extract::<IndexInstrument>(py)?,
549        )),
550        stringify!(OptionContract) => Ok(InstrumentAny::OptionContract(
551            instrument.extract::<OptionContract>(py)?,
552        )),
553        stringify!(OptionSpread) => Ok(InstrumentAny::OptionSpread(
554            instrument.extract::<OptionSpread>(py)?,
555        )),
556        stringify!(PerpetualContract) => Ok(InstrumentAny::PerpetualContract(
557            instrument.extract::<PerpetualContract>(py)?,
558        )),
559        stringify!(TokenizedAsset) => Ok(InstrumentAny::TokenizedAsset(
560            instrument.extract::<TokenizedAsset>(py)?,
561        )),
562        _ => Err(to_pyvalue_err(
563            "Error in conversion from `Py<PyAny>` to `InstrumentAny`",
564        )),
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use pyo3::{prelude::*, types::PyDict};
571    use rstest::rstest;
572
573    use super::register_crypto_currencies_from_dict;
574    use crate::{enums::CurrencyType, types::Currency};
575
576    #[rstest]
577    fn test_register_crypto_currencies_from_dict_unknown_code() {
578        Python::initialize();
579        Python::attach(|py| {
580            let dict = PyDict::new(py);
581            dict.set_item("base_currency", "NEWHLP1").unwrap();
582            let values: Py<PyDict> = dict.unbind();
583
584            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
585
586            let created = Currency::try_from_str("NEWHLP1").unwrap();
587            assert_eq!(created.precision, 8);
588            assert_eq!(created.currency_type, CurrencyType::Crypto);
589        });
590    }
591
592    #[rstest]
593    fn test_register_crypto_currencies_from_dict_known_code_not_overwritten() {
594        Python::initialize();
595        Python::attach(|py| {
596            let dict = PyDict::new(py);
597            dict.set_item("quote_currency", "USD").unwrap();
598            let values: Py<PyDict> = dict.unbind();
599
600            register_crypto_currencies_from_dict(py, &values, &["quote_currency"]);
601
602            let usd = Currency::try_from_str("USD").unwrap();
603            assert_eq!(usd.precision, 2);
604            assert_eq!(usd.currency_type, CurrencyType::Fiat);
605        });
606    }
607
608    #[rstest]
609    fn test_register_crypto_currencies_from_dict_missing_key() {
610        Python::initialize();
611        Python::attach(|py| {
612            let dict = PyDict::new(py);
613            let values: Py<PyDict> = dict.unbind();
614
615            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
616
617            assert!(Currency::try_from_str("base_currency").is_none());
618        });
619    }
620
621    #[rstest]
622    fn test_register_crypto_currencies_from_dict_non_string_value() {
623        Python::initialize();
624        Python::attach(|py| {
625            let dict = PyDict::new(py);
626            dict.set_item("base_currency", 42).unwrap();
627            let values: Py<PyDict> = dict.unbind();
628
629            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
630
631            assert!(Currency::try_from_str("42").is_none());
632        });
633    }
634
635    #[rstest]
636    fn test_register_crypto_currencies_from_dict_trims_padding() {
637        // Whitespace-padded codes must be trimmed before registration so the
638        // global map doesn't accumulate `" BTC "`-style garbage entries.
639        Python::initialize();
640        Python::attach(|py| {
641            let dict = PyDict::new(py);
642            dict.set_item("base_currency", "  NEWHLP2  ").unwrap();
643            let values: Py<PyDict> = dict.unbind();
644
645            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
646
647            assert!(Currency::try_from_str("NEWHLP2").is_some());
648            assert!(Currency::try_from_str("  NEWHLP2  ").is_none());
649        });
650    }
651
652    #[rstest]
653    fn test_register_crypto_currencies_from_dict_blank_code_skipped() {
654        // Blank or whitespace-only codes must be skipped so strict deserialize produces
655        // a normal PyErr, not a panic from `Currency::new` via get_or_create_crypto.
656        Python::initialize();
657        Python::attach(|py| {
658            let dict = PyDict::new(py);
659            dict.set_item("base_currency", "").unwrap();
660            dict.set_item("quote_currency", "   ").unwrap();
661            let values: Py<PyDict> = dict.unbind();
662
663            register_crypto_currencies_from_dict(py, &values, &["base_currency", "quote_currency"]);
664
665            assert!(Currency::try_from_str("").is_none());
666            assert!(Currency::try_from_str("   ").is_none());
667        });
668    }
669}