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