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 perpetuals 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 an error 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 an error 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    "margin_init",
490    py_margin_init,
491    Decimal,
492    margin_init,
493    BettingInstrument,
494    IndexInstrument,
495);
496impl_instrument_getter!(
497    "margin_maint",
498    py_margin_maint,
499    Decimal,
500    margin_maint,
501    BettingInstrument,
502    IndexInstrument,
503);
504impl_instrument_getter!(
505    "max_notional",
506    py_max_notional,
507    Option<Money>,
508    max_notional,
509    Equity,
510    FuturesContract,
511    FuturesSpread,
512    IndexInstrument,
513    OptionContract,
514    OptionSpread,
515);
516impl_instrument_getter!(
517    "max_price",
518    py_max_price,
519    Option<Price>,
520    max_price,
521    IndexInstrument
522);
523impl_instrument_getter!(
524    "max_quantity",
525    py_max_quantity,
526    Option<Quantity>,
527    max_quantity,
528    IndexInstrument,
529);
530impl_instrument_getter!(
531    "min_notional",
532    py_min_notional,
533    Option<Money>,
534    min_notional,
535    Equity,
536    FuturesContract,
537    FuturesSpread,
538    IndexInstrument,
539    OptionContract,
540    OptionSpread,
541);
542impl_instrument_getter!(
543    "min_price",
544    py_min_price,
545    Option<Price>,
546    min_price,
547    IndexInstrument
548);
549impl_instrument_getter!(
550    "min_quantity",
551    py_min_quantity,
552    Option<Quantity>,
553    min_quantity,
554    IndexInstrument,
555);
556impl_instrument_getter!(
557    "multiplier",
558    py_multiplier,
559    Quantity,
560    multiplier,
561    BettingInstrument,
562    BinaryOption,
563    Cfd,
564    Commodity,
565    Equity,
566    IndexInstrument,
567);
568impl_instrument_getter!(
569    "quote_currency",
570    py_quote_currency,
571    Currency,
572    quote_currency,
573    BettingInstrument,
574    BinaryOption,
575    FuturesContract,
576    FuturesSpread,
577    OptionContract,
578    OptionSpread,
579);
580
581pub mod betting;
582pub mod binary_option;
583pub mod cfd;
584pub mod commodity;
585pub mod crypto_future;
586pub mod crypto_futures_spread;
587pub mod crypto_option;
588pub mod crypto_option_spread;
589pub mod crypto_perpetual;
590pub mod currency_pair;
591pub mod equity;
592pub mod futures_contract;
593pub mod futures_spread;
594pub mod index_instrument;
595pub mod option_contract;
596pub mod option_spread;
597pub mod perpetual_contract;
598pub mod synthetic;
599pub mod tick_scheme;
600pub mod tokenized_asset;
601
602/// Converts an [`InstrumentAny`] into a Python object.
603///
604/// # Errors
605///
606/// Returns an error if conversion to a Python object fails.
607pub fn instrument_any_to_pyobject(py: Python, instrument: InstrumentAny) -> PyResult<Py<PyAny>> {
608    match instrument {
609        InstrumentAny::Betting(inst) => inst.into_py_any(py),
610        InstrumentAny::BinaryOption(inst) => inst.into_py_any(py),
611        InstrumentAny::Cfd(inst) => inst.into_py_any(py),
612        InstrumentAny::Commodity(inst) => inst.into_py_any(py),
613        InstrumentAny::CryptoFuture(inst) => inst.into_py_any(py),
614        InstrumentAny::CryptoFuturesSpread(inst) => inst.into_py_any(py),
615        InstrumentAny::CryptoOption(inst) => inst.into_py_any(py),
616        InstrumentAny::CryptoOptionSpread(inst) => inst.into_py_any(py),
617        InstrumentAny::CryptoPerpetual(inst) => inst.into_py_any(py),
618        InstrumentAny::CurrencyPair(inst) => inst.into_py_any(py),
619        InstrumentAny::Equity(inst) => inst.into_py_any(py),
620        InstrumentAny::FuturesContract(inst) => inst.into_py_any(py),
621        InstrumentAny::FuturesSpread(inst) => inst.into_py_any(py),
622        InstrumentAny::IndexInstrument(inst) => inst.into_py_any(py),
623        InstrumentAny::OptionContract(inst) => inst.into_py_any(py),
624        InstrumentAny::OptionSpread(inst) => inst.into_py_any(py),
625        InstrumentAny::PerpetualContract(inst) => inst.into_py_any(py),
626        InstrumentAny::TokenizedAsset(inst) => inst.into_py_any(py),
627    }
628}
629
630/// Converts a Python object into an [`InstrumentAny`] enum.
631///
632/// # Errors
633///
634/// Returns an error if extraction fails or the instrument type is unsupported.
635#[expect(clippy::needless_pass_by_value)]
636pub fn pyobject_to_instrument_any(py: Python, instrument: Py<PyAny>) -> PyResult<InstrumentAny> {
637    match instrument.getattr(py, "type_name")?.extract::<&str>(py)? {
638        stringify!(BettingInstrument) => Ok(InstrumentAny::Betting(
639            instrument.extract::<BettingInstrument>(py)?,
640        )),
641        stringify!(BinaryOption) => Ok(InstrumentAny::BinaryOption(
642            instrument.extract::<BinaryOption>(py)?,
643        )),
644        stringify!(Cfd) => Ok(InstrumentAny::Cfd(instrument.extract::<Cfd>(py)?)),
645        stringify!(Commodity) => Ok(InstrumentAny::Commodity(
646            instrument.extract::<Commodity>(py)?,
647        )),
648        stringify!(CryptoFuture) => Ok(InstrumentAny::CryptoFuture(
649            instrument.extract::<CryptoFuture>(py)?,
650        )),
651        stringify!(CryptoFuturesSpread) => Ok(InstrumentAny::CryptoFuturesSpread(
652            instrument.extract::<CryptoFuturesSpread>(py)?,
653        )),
654        stringify!(CryptoOption) => Ok(InstrumentAny::CryptoOption(
655            instrument.extract::<CryptoOption>(py)?,
656        )),
657        stringify!(CryptoOptionSpread) => Ok(InstrumentAny::CryptoOptionSpread(
658            instrument.extract::<CryptoOptionSpread>(py)?,
659        )),
660        stringify!(CryptoPerpetual) => Ok(InstrumentAny::CryptoPerpetual(
661            instrument.extract::<CryptoPerpetual>(py)?,
662        )),
663        stringify!(CurrencyPair) => Ok(InstrumentAny::CurrencyPair(
664            instrument.extract::<CurrencyPair>(py)?,
665        )),
666        stringify!(Equity) => Ok(InstrumentAny::Equity(instrument.extract::<Equity>(py)?)),
667        stringify!(FuturesContract) => Ok(InstrumentAny::FuturesContract(
668            instrument.extract::<FuturesContract>(py)?,
669        )),
670        stringify!(FuturesSpread) => Ok(InstrumentAny::FuturesSpread(
671            instrument.extract::<FuturesSpread>(py)?,
672        )),
673        stringify!(IndexInstrument) => Ok(InstrumentAny::IndexInstrument(
674            instrument.extract::<IndexInstrument>(py)?,
675        )),
676        stringify!(OptionContract) => Ok(InstrumentAny::OptionContract(
677            instrument.extract::<OptionContract>(py)?,
678        )),
679        stringify!(OptionSpread) => Ok(InstrumentAny::OptionSpread(
680            instrument.extract::<OptionSpread>(py)?,
681        )),
682        stringify!(PerpetualContract) => Ok(InstrumentAny::PerpetualContract(
683            instrument.extract::<PerpetualContract>(py)?,
684        )),
685        stringify!(TokenizedAsset) => Ok(InstrumentAny::TokenizedAsset(
686            instrument.extract::<TokenizedAsset>(py)?,
687        )),
688        _ => Err(to_pyvalue_err(
689            "Error in conversion from `Py<PyAny>` to `InstrumentAny`",
690        )),
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use pyo3::{prelude::*, types::PyDict};
697    use rstest::rstest;
698
699    use super::{
700        MAX_PRICE_LIST_TICKS, register_crypto_currencies_from_dict, validate_price_list_ticks,
701    };
702    use crate::{enums::CurrencyType, instruments::stubs::audusd_sim, types::Currency};
703
704    #[rstest]
705    #[case(0)]
706    #[case(100)]
707    #[case(MAX_PRICE_LIST_TICKS)]
708    fn test_validate_price_list_ticks_accepts_supported_values(#[case] num_ticks: usize) {
709        assert!(validate_price_list_ticks(num_ticks).is_ok());
710    }
711
712    #[rstest]
713    #[case(MAX_PRICE_LIST_TICKS + 1)]
714    #[case(usize::MAX)]
715    fn test_validate_price_list_ticks_rejects_oversized_values(#[case] num_ticks: usize) {
716        assert!(validate_price_list_ticks(num_ticks).is_err());
717    }
718
719    #[rstest]
720    fn test_next_bid_prices_materializes_maximum_supported_list() {
721        let prices = audusd_sim()
722            .py_next_bid_prices(1.0, MAX_PRICE_LIST_TICKS)
723            .unwrap();
724
725        assert_eq!(prices.len(), MAX_PRICE_LIST_TICKS);
726    }
727
728    #[rstest]
729    fn test_register_crypto_currencies_from_dict_unknown_code() {
730        Python::initialize();
731        Python::attach(|py| {
732            let dict = PyDict::new(py);
733            dict.set_item("base_currency", "NEWHLP1").unwrap();
734            let values: Py<PyDict> = dict.unbind();
735
736            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
737
738            let created = Currency::try_from_str("NEWHLP1").unwrap();
739            assert_eq!(created.precision, 8);
740            assert_eq!(created.currency_type, CurrencyType::Crypto);
741        });
742    }
743
744    #[rstest]
745    fn test_register_crypto_currencies_from_dict_known_code_not_overwritten() {
746        Python::initialize();
747        Python::attach(|py| {
748            let dict = PyDict::new(py);
749            dict.set_item("quote_currency", "USD").unwrap();
750            let values: Py<PyDict> = dict.unbind();
751
752            register_crypto_currencies_from_dict(py, &values, &["quote_currency"]);
753
754            let usd = Currency::try_from_str("USD").unwrap();
755            assert_eq!(usd.precision, 2);
756            assert_eq!(usd.currency_type, CurrencyType::Fiat);
757        });
758    }
759
760    #[rstest]
761    fn test_register_crypto_currencies_from_dict_missing_key() {
762        Python::initialize();
763        Python::attach(|py| {
764            let dict = PyDict::new(py);
765            let values: Py<PyDict> = dict.unbind();
766
767            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
768
769            assert!(Currency::try_from_str("base_currency").is_none());
770        });
771    }
772
773    #[rstest]
774    fn test_register_crypto_currencies_from_dict_non_string_value() {
775        Python::initialize();
776        Python::attach(|py| {
777            let dict = PyDict::new(py);
778            dict.set_item("base_currency", 42).unwrap();
779            let values: Py<PyDict> = dict.unbind();
780
781            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
782
783            assert!(Currency::try_from_str("42").is_none());
784        });
785    }
786
787    #[rstest]
788    fn test_register_crypto_currencies_from_dict_trims_padding() {
789        // Whitespace-padded codes must be trimmed before registration so the
790        // global map doesn't accumulate `" BTC "`-style garbage entries.
791        Python::initialize();
792        Python::attach(|py| {
793            let dict = PyDict::new(py);
794            dict.set_item("base_currency", "  NEWHLP2  ").unwrap();
795            let values: Py<PyDict> = dict.unbind();
796
797            register_crypto_currencies_from_dict(py, &values, &["base_currency"]);
798
799            assert!(Currency::try_from_str("NEWHLP2").is_some());
800            assert!(Currency::try_from_str("  NEWHLP2  ").is_none());
801        });
802    }
803
804    #[rstest]
805    fn test_register_crypto_currencies_from_dict_blank_code_skipped() {
806        // Blank or whitespace-only codes must be skipped so strict deserialize produces
807        // a normal PyErr, not a panic from `Currency::new` via get_or_create_crypto.
808        Python::initialize();
809        Python::attach(|py| {
810            let dict = PyDict::new(py);
811            dict.set_item("base_currency", "").unwrap();
812            dict.set_item("quote_currency", "   ").unwrap();
813            let values: Py<PyDict> = dict.unbind();
814
815            register_crypto_currencies_from_dict(py, &values, &["base_currency", "quote_currency"]);
816
817            assert!(Currency::try_from_str("").is_none());
818            assert!(Currency::try_from_str("   ").is_none());
819        });
820    }
821}