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