Skip to main content

nautilus_model/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 for the trading domain model.
17
18pub mod any;
19pub mod betting;
20pub mod binary_option;
21pub mod cfd;
22pub mod commodity;
23pub mod crypto_future;
24pub mod crypto_futures_spread;
25pub mod crypto_option;
26pub mod crypto_option_spread;
27pub mod crypto_perpetual;
28pub mod currency_pair;
29pub mod equity;
30pub mod futures_contract;
31pub mod futures_spread;
32pub mod index_instrument;
33pub mod option_contract;
34pub mod option_spread;
35pub mod perpetual_contract;
36pub mod synthetic;
37pub mod tick_scheme;
38pub mod tokenized_asset;
39
40#[cfg(any(test, feature = "stubs"))]
41pub mod stubs;
42
43use std::{fmt::Display, str::FromStr};
44
45use enum_dispatch::enum_dispatch;
46use nautilus_core::{
47    UnixNanos,
48    correctness::{
49        CorrectnessError, CorrectnessResult, check_equal_u8, check_positive_decimal,
50        check_predicate_true,
51    },
52    string::parsing::min_increment_precision_from_str,
53};
54use rust_decimal::{Decimal, RoundingStrategy};
55use rust_decimal_macros::dec;
56use ustr::Ustr;
57
58pub use crate::instruments::{
59    any::InstrumentAny,
60    betting::BettingInstrument,
61    binary_option::BinaryOption,
62    cfd::Cfd,
63    commodity::Commodity,
64    crypto_future::CryptoFuture,
65    crypto_futures_spread::CryptoFuturesSpread,
66    crypto_option::CryptoOption,
67    crypto_option_spread::CryptoOptionSpread,
68    crypto_perpetual::CryptoPerpetual,
69    currency_pair::CurrencyPair,
70    equity::Equity,
71    futures_contract::FuturesContract,
72    futures_spread::FuturesSpread,
73    index_instrument::IndexInstrument,
74    option_contract::OptionContract,
75    option_spread::OptionSpread,
76    perpetual_contract::PerpetualContract,
77    synthetic::{SyntheticInstrument, SyntheticInstrumentError},
78    tick_scheme::{
79        FixedTickScheme, TickScheme, TickSchemeError, TickSchemeRule, TieredTickScheme,
80        tick_scheme_rule_from_name,
81    },
82    tokenized_asset::TokenizedAsset,
83};
84use crate::{
85    enums::{AssetClass, InstrumentClass, OptionKind},
86    identifiers::{InstrumentId, Symbol, Venue},
87    types::{
88        Currency, ERROR_PRICE, Money, PRICE_ERROR, Price, Quantity,
89        fixed::{FIXED_PRECISION, raw_scales_match},
90        money::check_positive_money,
91        price::{PriceRaw, check_positive_price},
92        quantity::{QuantityRaw, check_positive_quantity},
93    },
94};
95
96#[expect(clippy::missing_errors_doc, clippy::too_many_arguments)]
97pub fn validate_instrument_common(
98    price_precision: u8,
99    size_precision: u8,
100    size_increment: Quantity,
101    multiplier: Quantity,
102    margin_init: Decimal,
103    margin_maint: Decimal,
104    price_increment: Option<Price>,
105    lot_size: Option<Quantity>,
106    max_quantity: Option<Quantity>,
107    min_quantity: Option<Quantity>,
108    max_notional: Option<Money>,
109    min_notional: Option<Money>,
110    max_price: Option<Price>,
111    min_price: Option<Price>,
112) -> CorrectnessResult<()> {
113    check_positive_quantity(size_increment, "size_increment")?;
114    check_equal_u8(
115        size_increment.precision,
116        size_precision,
117        "size_increment.precision",
118        "size_precision",
119    )?;
120    check_positive_quantity(multiplier, "multiplier")?;
121    check_positive_decimal(margin_init, "margin_init")?;
122    check_positive_decimal(margin_maint, "margin_maint")?;
123
124    if let Some(price_increment) = price_increment {
125        check_positive_price(price_increment, "price_increment")?;
126        check_equal_u8(
127            price_increment.precision,
128            price_precision,
129            "price_increment.precision",
130            "price_precision",
131        )?;
132    }
133
134    if let Some(lot) = lot_size {
135        check_positive_quantity(lot, "lot_size")?;
136    }
137
138    if let Some(quantity) = max_quantity {
139        check_positive_quantity(quantity, "max_quantity")?;
140    }
141
142    if let Some(quantity) = min_quantity {
143        check_positive_quantity(quantity, "min_quantity")?;
144    }
145
146    if let Some(notional) = max_notional {
147        check_positive_money(notional, "max_notional")?;
148    }
149
150    if let Some(notional) = min_notional {
151        check_positive_money(notional, "min_notional")?;
152    }
153
154    if let Some(max_price) = max_price {
155        check_positive_price(max_price, "max_price")?;
156        check_equal_u8(
157            max_price.precision,
158            price_precision,
159            "max_price.precision",
160            "price_precision",
161        )?;
162    }
163
164    if let Some(min_price) = min_price {
165        check_positive_price(min_price, "min_price")?;
166        check_equal_u8(
167            min_price.precision,
168            price_precision,
169            "min_price.precision",
170            "price_precision",
171        )?;
172    }
173
174    if let (Some(min), Some(max)) = (min_price, max_price) {
175        check_predicate_true(min.raw <= max.raw, "min_price exceeds max_price")?;
176    }
177
178    Ok(())
179}
180
181fn currencies_equivalent_for_quanto(left: Currency, right: Currency) -> bool {
182    if left == right {
183        return true;
184    }
185
186    is_usd_equivalent_currency(left) && is_usd_equivalent_currency(right)
187}
188
189fn is_usd_equivalent_currency(currency: Currency) -> bool {
190    matches!(
191        currency.code.as_str(),
192        "BUSD" | "FDUSD" | "pUSD" | "TUSD" | "USD" | "USDC" | "USDC.e" | "USDP" | "USDT"
193    )
194}
195
196#[enum_dispatch]
197pub trait Instrument: 'static + Send {
198    fn tick_scheme(&self) -> Option<Ustr> {
199        None
200    }
201
202    fn tick_scheme_rule(&self) -> Option<&dyn TickSchemeRule> {
203        self.tick_scheme()
204            .and_then(|scheme| tick_scheme_rule_from_name(scheme.as_str()))
205    }
206
207    fn into_any(self) -> InstrumentAny
208    where
209        Self: Sized,
210        InstrumentAny: From<Self>,
211    {
212        self.into()
213    }
214
215    fn id(&self) -> InstrumentId;
216    fn symbol(&self) -> Symbol {
217        self.id().symbol
218    }
219    fn venue(&self) -> Venue {
220        self.id().venue
221    }
222
223    fn raw_symbol(&self) -> Symbol;
224    fn asset_class(&self) -> AssetClass;
225    fn instrument_class(&self) -> InstrumentClass;
226
227    fn underlying(&self) -> Option<Ustr>;
228    fn base_currency(&self) -> Option<Currency>;
229    fn quote_currency(&self) -> Currency;
230    fn settlement_currency(&self) -> Currency;
231
232    /// # Panics
233    ///
234    /// Panics if the instrument is inverse and does not have a base currency.
235    fn cost_currency(&self) -> Currency {
236        if self.is_inverse() {
237            self.base_currency()
238                .expect("inverse instrument without base_currency")
239        } else if self.is_quanto() {
240            self.settlement_currency()
241        } else {
242            self.quote_currency()
243        }
244    }
245
246    fn isin(&self) -> Option<Ustr>;
247    fn option_kind(&self) -> Option<OptionKind>;
248    fn exchange(&self) -> Option<Ustr>;
249    fn strike_price(&self) -> Option<Price>;
250    fn strategy_type(&self) -> Option<Ustr> {
251        None
252    }
253
254    fn activation_ns(&self) -> Option<UnixNanos>;
255    fn expiration_ns(&self) -> Option<UnixNanos>;
256    fn has_expiration(&self) -> bool {
257        self.instrument_class().has_expiration()
258    }
259
260    fn allows_negative_price(&self) -> bool {
261        self.instrument_class().allows_negative_price()
262    }
263
264    fn is_inverse(&self) -> bool;
265    fn is_quanto(&self) -> bool {
266        self.base_currency().is_some_and(|base_currency| {
267            self.settlement_currency() != base_currency
268                && !currencies_equivalent_for_quanto(
269                    self.settlement_currency(),
270                    self.quote_currency(),
271                )
272        })
273    }
274
275    fn price_precision(&self) -> u8;
276    fn size_precision(&self) -> u8;
277    fn price_increment(&self) -> Price;
278    fn size_increment(&self) -> Quantity;
279
280    fn multiplier(&self) -> Quantity;
281    fn lot_size(&self) -> Option<Quantity>;
282    fn max_quantity(&self) -> Option<Quantity>;
283    fn min_quantity(&self) -> Option<Quantity>;
284    fn max_notional(&self) -> Option<Money>;
285    fn min_notional(&self) -> Option<Money>;
286    fn max_price(&self) -> Option<Price>;
287    fn min_price(&self) -> Option<Price>;
288
289    fn margin_init(&self) -> Decimal {
290        dec!(0)
291    }
292    fn margin_maint(&self) -> Decimal {
293        dec!(0)
294    }
295    fn maker_fee(&self) -> Decimal {
296        dec!(0)
297    }
298    fn taker_fee(&self) -> Decimal {
299        dec!(0)
300    }
301
302    fn ts_event(&self) -> UnixNanos;
303    fn ts_init(&self) -> UnixNanos;
304
305    fn min_price_increment_precision(&self) -> u8 {
306        // TODO: Optimize by storing min price increment precision (without trailing zeros)
307        min_increment_precision_from_str(&self.price_increment().to_string())
308    }
309
310    fn min_size_increment_precision(&self) -> u8 {
311        // TODO: Optimize by storing min size increment precision (without trailing zeros)
312        min_increment_precision_from_str(&self.size_increment().to_string())
313    }
314
315    /// # Errors
316    ///
317    /// Returns an error if the value cannot be converted to a `Price`.
318    #[inline(always)]
319    fn try_make_price_from_decimal(&self, value: Decimal) -> anyhow::Result<Price> {
320        let precision = u32::from(self.min_price_increment_precision());
321        let rounded_decimal =
322            value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
323        Price::from_decimal_dp(rounded_decimal, self.price_precision()).map_err(Into::into)
324    }
325
326    /// # Panics
327    ///
328    /// Panics if the value cannot be converted to a `Price` (see `try_make_price_from_decimal`).
329    fn make_price_from_decimal(&self, value: Decimal) -> Price {
330        self.try_make_price_from_decimal(value).unwrap()
331    }
332
333    /// # Errors
334    ///
335    /// Returns an error if the value is not finite, not representable as a `Decimal`, or cannot
336    /// be converted to a `Price`.
337    #[inline(always)]
338    fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
339        let dec_value = Decimal::from_str(&value.to_string())
340            .map_err(|_| anyhow::anyhow!("invalid `value` for make_price, was {value}"))?;
341        self.try_make_price_from_decimal(dec_value)
342    }
343
344    /// # Panics
345    ///
346    /// Panics if the value cannot be converted to a `Price` (see `try_make_price`).
347    fn make_price(&self, value: f64) -> Price {
348        self.try_make_price(value).unwrap()
349    }
350
351    /// Returns `price` rebuilt with the instrument precision when it is on the price grid.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error when `price` is a sentinel value or would require rounding.
356    #[inline(always)]
357    fn try_normalize_price(&self, price: Price) -> CorrectnessResult<Price> {
358        if price == ERROR_PRICE {
359            return Err(CorrectnessError::InvalidValue {
360                param: "price".to_string(),
361                value: "ERROR_PRICE".to_string(),
362                type_name: "`Price`",
363            });
364        }
365
366        if price.raw == PRICE_ERROR {
367            return Err(CorrectnessError::InvalidValue {
368                param: "price".to_string(),
369                value: "PRICE_ERROR".to_string(),
370                type_name: "`Price`",
371            });
372        }
373
374        if price.is_undefined() {
375            return Err(CorrectnessError::InvalidValue {
376                param: "price".to_string(),
377                value: "PRICE_UNDEF".to_string(),
378                type_name: "`Price`",
379            });
380        }
381
382        let precision = self.price_precision();
383        let increment = self.price_increment();
384
385        if !raw_scales_match(price.precision, precision) {
386            return Err(CorrectnessError::PredicateViolation {
387                message: format!(
388                    "`price` raw scale does not match instrument price precision, price precision was {}, instrument price precision was {precision}",
389                    price.precision
390                ),
391            });
392        }
393
394        if !raw_scales_match(price.precision, increment.precision) {
395            return Err(CorrectnessError::PredicateViolation {
396                message: format!(
397                    "`price` raw scale does not match price increment precision, price precision was {}, price increment precision was {}",
398                    price.precision, increment.precision
399                ),
400            });
401        }
402
403        let precision_diff = FIXED_PRECISION.saturating_sub(precision);
404        let scale = PriceRaw::pow(10, u32::from(precision_diff));
405
406        if price.raw % scale != 0 {
407            return Err(CorrectnessError::PredicateViolation {
408                message: format!(
409                    "`price` requires rounding to instrument price precision {precision}, was {price}"
410                ),
411            });
412        }
413
414        let increment_raw = increment.raw.abs();
415        if increment_raw != 0 && price.raw % increment_raw != 0 {
416            return Err(CorrectnessError::PredicateViolation {
417                message: format!(
418                    "`price` is not aligned to price increment {increment}, was {price}"
419                ),
420            });
421        }
422
423        Price::from_raw_checked(price.raw, precision)
424    }
425
426    /// # Errors
427    ///
428    /// Returns an error if the value rounds to zero or cannot be converted to a `Quantity`.
429    #[inline(always)]
430    fn try_make_qty_from_decimal(
431        &self,
432        value: Decimal,
433        round_down: Option<bool>,
434    ) -> anyhow::Result<Quantity> {
435        let precision = u32::from(self.min_size_increment_precision());
436
437        let strategy = if round_down.unwrap_or(false) {
438            RoundingStrategy::ToZero
439        } else {
440            RoundingStrategy::MidpointNearestEven
441        };
442
443        let rounded = value.round_dp_with_strategy(precision, strategy);
444        if value > Decimal::ZERO && rounded.is_zero() {
445            anyhow::bail!("value rounded to zero for quantity");
446        }
447
448        Quantity::from_decimal_dp(rounded, self.size_precision()).map_err(Into::into)
449    }
450
451    /// # Panics
452    ///
453    /// Panics if the value cannot be converted to a `Quantity` (see `try_make_qty_from_decimal`).
454    fn make_qty_from_decimal(&self, value: Decimal, round_down: Option<bool>) -> Quantity {
455        self.try_make_qty_from_decimal(value, round_down).unwrap()
456    }
457
458    /// # Errors
459    ///
460    /// Returns an error if the value is not finite, not representable as a `Decimal`, rounds to
461    /// zero, or cannot be converted to a `Quantity`.
462    #[inline(always)]
463    fn try_make_qty(&self, value: f64, round_down: Option<bool>) -> anyhow::Result<Quantity> {
464        let dec_value = Decimal::from_str(&value.to_string())
465            .map_err(|_| anyhow::anyhow!("invalid `value` for make_qty, was {value}"))?;
466        self.try_make_qty_from_decimal(dec_value, round_down)
467    }
468
469    /// # Panics
470    ///
471    /// Panics if the value cannot be converted to a `Quantity` (see `try_make_qty`).
472    fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
473        self.try_make_qty(value, round_down).unwrap()
474    }
475
476    /// Returns `quantity` rebuilt with the instrument precision when it is on the size grid.
477    ///
478    /// # Errors
479    ///
480    /// Returns an error when `quantity` is undefined or would require rounding.
481    #[inline(always)]
482    fn try_normalize_qty(&self, quantity: Quantity) -> CorrectnessResult<Quantity> {
483        if quantity.is_undefined() {
484            return Err(CorrectnessError::InvalidValue {
485                param: "quantity".to_string(),
486                value: "QUANTITY_UNDEF".to_string(),
487                type_name: "`Quantity`",
488            });
489        }
490
491        let precision = self.size_precision();
492        let increment = self.size_increment();
493
494        if !raw_scales_match(quantity.precision, precision) {
495            return Err(CorrectnessError::PredicateViolation {
496                message: format!(
497                    "`quantity` raw scale does not match instrument size precision, quantity precision was {}, instrument size precision was {precision}",
498                    quantity.precision
499                ),
500            });
501        }
502
503        if !raw_scales_match(quantity.precision, increment.precision) {
504            return Err(CorrectnessError::PredicateViolation {
505                message: format!(
506                    "`quantity` raw scale does not match size increment precision, quantity precision was {}, size increment precision was {}",
507                    quantity.precision, increment.precision
508                ),
509            });
510        }
511
512        let precision_diff = FIXED_PRECISION.saturating_sub(precision);
513        let scale = QuantityRaw::pow(10, u32::from(precision_diff));
514
515        if !quantity.raw.is_multiple_of(scale) {
516            return Err(CorrectnessError::PredicateViolation {
517                message: format!(
518                    "`quantity` requires rounding to instrument size precision {precision}, was {quantity}"
519                ),
520            });
521        }
522
523        if increment.raw != 0 && !quantity.raw.is_multiple_of(increment.raw) {
524            return Err(CorrectnessError::PredicateViolation {
525                message: format!(
526                    "`quantity` is not aligned to size increment {increment}, was {quantity}"
527                ),
528            });
529        }
530
531        Quantity::from_raw_checked(quantity.raw, precision)
532    }
533
534    /// # Errors
535    ///
536    /// Returns an error if `last_price` is zero, or if the value cannot be converted to a
537    /// `Quantity`.
538    fn try_calculate_base_quantity(
539        &self,
540        quantity: Quantity,
541        last_price: Price,
542    ) -> anyhow::Result<Quantity> {
543        let last_px = last_price.as_decimal();
544        if last_px.is_zero() {
545            anyhow::bail!("`last_price` was zero when calculating base quantity");
546        }
547        let precision = u32::from(self.min_size_increment_precision());
548        let value = (quantity.as_decimal() / last_px)
549            .round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
550        Quantity::from_decimal_dp(value, self.size_precision()).map_err(Into::into)
551    }
552
553    /// # Panics
554    ///
555    /// Panics if `last_price` is zero, or if the value cannot be converted to a `Quantity`
556    /// (see `try_calculate_base_quantity`).
557    fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
558        self.try_calculate_base_quantity(quantity, last_price)
559            .unwrap()
560    }
561
562    /// Calculates the notional value for the given quantity and price.
563    ///
564    /// # Errors
565    ///
566    /// Returns an error if base-denominated inverse valuation lacks a base currency or positive
567    /// price, or if the result cannot be represented as [`Money`].
568    #[inline(always)]
569    fn try_calculate_notional_value(
570        &self,
571        quantity: Quantity,
572        price: Price,
573        use_quote_for_inverse: Option<bool>,
574    ) -> anyhow::Result<Money> {
575        let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
576        let currency = if self.is_inverse() {
577            if use_quote_inverse {
578                self.quote_currency()
579            } else {
580                self.base_currency().ok_or_else(|| {
581                    anyhow::anyhow!("inverse instrument {} has no base currency", self.id())
582                })?
583            }
584        } else if self.is_quanto() {
585            self.settlement_currency()
586        } else {
587            self.quote_currency()
588        };
589
590        try_notional_value(
591            quantity,
592            price,
593            self.multiplier(),
594            self.is_inverse(),
595            use_quote_inverse,
596            currency,
597        )
598    }
599
600    /// # Panics
601    ///
602    /// Panics if [`Instrument::try_calculate_notional_value`] returns an error.
603    #[inline(always)]
604    fn calculate_notional_value(
605        &self,
606        quantity: Quantity,
607        price: Price,
608        use_quote_for_inverse: Option<bool>,
609    ) -> Money {
610        self.try_calculate_notional_value(quantity, price, use_quote_for_inverse)
611            .expect("invalid notional value")
612    }
613
614    #[inline(always)]
615    fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
616        if n < 0 {
617            return None;
618        }
619
620        let price = if let Some(scheme) = self.tick_scheme_rule() {
621            scheme.next_bid_price(value, n, self.price_precision())?
622        } else {
623            let value = Decimal::from_str(&value.to_string()).ok()?;
624            let increment = self.price_increment().as_decimal();
625            if increment.is_zero() {
626                return None;
627            }
628            let base = (value / increment).floor() * increment;
629            let result = base - Decimal::from(n) * increment;
630            Price::from_decimal_dp(result, self.price_precision()).ok()?
631        };
632
633        if self.min_price().is_some_and(|min| price < min)
634            || self.max_price().is_some_and(|max| price > max)
635        {
636            return None;
637        }
638
639        Some(price)
640    }
641
642    #[inline(always)]
643    fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
644        if n < 0 {
645            return None;
646        }
647
648        let price = if let Some(scheme) = self.tick_scheme_rule() {
649            scheme.next_ask_price(value, n, self.price_precision())?
650        } else {
651            let value = Decimal::from_str(&value.to_string()).ok()?;
652            let increment = self.price_increment().as_decimal();
653            if increment.is_zero() {
654                return None;
655            }
656            let base = (value / increment).ceil() * increment;
657            let result = base + Decimal::from(n) * increment;
658            Price::from_decimal_dp(result, self.price_precision()).ok()?
659        };
660
661        if self.min_price().is_some_and(|min| price < min)
662            || self.max_price().is_some_and(|max| price > max)
663        {
664            return None;
665        }
666
667        Some(price)
668    }
669
670    #[inline]
671    fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
672        let mut prices = Vec::with_capacity(n);
673
674        for i in 0..n {
675            let Ok(i) = i32::try_from(i) else { break };
676            if let Some(price) = self.next_bid_price(value, i) {
677                prices.push(price);
678            } else {
679                break;
680            }
681        }
682
683        prices
684    }
685
686    #[inline]
687    fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
688        let mut prices = Vec::with_capacity(n);
689
690        for i in 0..n {
691            let Ok(i) = i32::try_from(i) else { break };
692            if let Some(price) = self.next_ask_price(value, i) {
693                prices.push(price);
694            } else {
695                break;
696            }
697        }
698
699        prices
700    }
701}
702
703pub(crate) fn try_notional_value(
704    quantity: Quantity,
705    price: Price,
706    multiplier: Quantity,
707    is_inverse: bool,
708    use_quote_for_inverse: bool,
709    currency: Currency,
710) -> anyhow::Result<Money> {
711    let amount = if is_inverse && !use_quote_for_inverse {
712        anyhow::ensure!(
713            price.is_positive(),
714            "price must be positive for inverse notional valuation"
715        );
716        quantity
717            .as_decimal()
718            .checked_mul(multiplier.as_decimal())
719            .and_then(|value| value.checked_div(price.as_decimal()))
720            .ok_or_else(|| anyhow::anyhow!("inverse notional calculation overflow"))?
721    } else if is_inverse {
722        quantity.as_decimal()
723    } else {
724        quantity
725            .as_decimal()
726            .checked_mul(multiplier.as_decimal())
727            .and_then(|value| value.checked_mul(price.as_decimal()))
728            .ok_or_else(|| anyhow::anyhow!("notional calculation overflow"))?
729    };
730
731    Money::from_decimal(amount, currency).map_err(Into::into)
732}
733
734impl Display for CurrencyPair {
735    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736        write!(
737            f,
738            "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
739price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
740            stringify!(CurrencyPair),
741            self.id,
742            self.tick_scheme()
743                .map_or_else(|| "None".into(), |s| s.to_string()),
744            self.price_precision(),
745            self.size_precision(),
746            self.price_increment(),
747            self.size_increment(),
748            self.multiplier(),
749            self.margin_init(),
750            self.margin_maint(),
751        )
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use nautilus_core::correctness::{CorrectnessResultExt, FAILED};
758    use proptest::prelude::*;
759    use rstest::rstest;
760    use rust_decimal::{Decimal, prelude::*};
761
762    use super::*;
763    use crate::{
764        instruments::stubs::*,
765        types::{ERROR_PRICE, Money, PRICE_ERROR, PRICE_UNDEF, QUANTITY_UNDEF},
766    };
767
768    pub(super) fn default_price_increment(precision: u8) -> Price {
769        let step = 10f64.powi(-i32::from(precision));
770        Price::new(step, precision)
771    }
772
773    #[rstest]
774    fn default_increment_precision() {
775        let inc = default_price_increment(2);
776        assert_eq!(inc, Price::new(0.01, 2));
777    }
778
779    #[rstest]
780    #[case(Price::new(0.5, 1), 1)] // 0.5 -> precision 1
781    #[case(Price::new(0.50, 2), 1)] // 0.50 -> precision 1 (trailing zero ignored)
782    #[case(Price::new(0.500, 3), 1)] // 0.500 -> precision 1
783    #[case(Price::new(0.01, 2), 2)] // 0.01 -> precision 2
784    #[case(Price::new(0.010, 3), 2)] // 0.010 -> precision 2
785    #[case(Price::new(0.25, 2), 2)] // 0.25 -> precision 2
786    #[case(Price::new(1.0, 1), 1)] // 1.0 -> precision 1
787    #[case(Price::new(1.00, 2), 2)] // 1.00 -> precision 2 (all zeros)
788    #[case(Price::new(100.0, 0), 0)] // 100 -> precision 0
789    #[case(Price::new(0.001, 3), 3)] // 0.001 -> precision 3
790    fn test_min_increment_precision(#[case] price: Price, #[case] expected: u8) {
791        assert_eq!(
792            nautilus_core::string::parsing::min_increment_precision_from_str(&price.to_string()),
793            expected
794        );
795    }
796
797    #[rstest]
798    #[case(1.5, "1.500000")]
799    #[case(2.5, "2.500000")]
800    #[case(1.234_567_8, "1.234568")]
801    #[case(0.000_123, "0.000123")]
802    #[case(99_999.999_999, "99999.999999")]
803    fn make_qty_rounding(
804        currency_pair_btcusdt: CurrencyPair,
805        #[case] input: f64,
806        #[case] expected: &str,
807    ) {
808        assert_eq!(
809            currency_pair_btcusdt.make_qty(input, None).to_string(),
810            expected
811        );
812    }
813
814    #[rstest]
815    #[case(1.234_567_8, "1.234567")]
816    #[case(1.999_999_9, "1.999999")]
817    #[case(0.000_123_45, "0.000123")]
818    #[case(10.999_999_9, "10.999999")]
819    fn make_qty_round_down(
820        currency_pair_btcusdt: CurrencyPair,
821        #[case] input: f64,
822        #[case] expected: &str,
823    ) {
824        assert_eq!(
825            currency_pair_btcusdt
826                .make_qty(input, Some(true))
827                .to_string(),
828            expected
829        );
830    }
831
832    #[rstest]
833    #[case(1.234_567_8, "1.23457")]
834    #[case(2.345_678_1, "2.34568")]
835    #[case(0.00001, "0.00001")]
836    fn make_qty_precision(
837        currency_pair_ethusdt: CurrencyPair,
838        #[case] input: f64,
839        #[case] expected: &str,
840    ) {
841        assert_eq!(
842            currency_pair_ethusdt.make_qty(input, None).to_string(),
843            expected
844        );
845    }
846
847    #[rstest]
848    #[case(1.234_567_5, "1.234568")]
849    #[case(1.234_566_5, "1.234566")]
850    fn make_qty_half_even(
851        currency_pair_btcusdt: CurrencyPair,
852        #[case] input: f64,
853        #[case] expected: &str,
854    ) {
855        assert_eq!(
856            currency_pair_btcusdt.make_qty(input, None).to_string(),
857            expected
858        );
859    }
860
861    #[rstest]
862    #[case(dec!(1.5), None, dec!(1.5))]
863    #[case(dec!(1.2345678), None, dec!(1.234568))]
864    #[case(dec!(1.2345678), Some(true), dec!(1.234567))]
865    #[case(dec!(1.9999999), Some(true), dec!(1.999999))]
866    #[case(dec!(0.000123), None, dec!(0.000123))]
867    fn make_qty_from_decimal_matches_f64_path(
868        currency_pair_btcusdt: CurrencyPair,
869        #[case] value: Decimal,
870        #[case] round_down: Option<bool>,
871        #[case] expected: Decimal,
872    ) {
873        let from_decimal = currency_pair_btcusdt.make_qty_from_decimal(value, round_down);
874        let from_f64 =
875            currency_pair_btcusdt.make_qty(value.to_string().parse::<f64>().unwrap(), round_down);
876        assert_eq!(from_decimal, from_f64);
877        assert_eq!(from_decimal.as_decimal(), expected);
878    }
879
880    #[rstest]
881    #[should_panic(expected = "value rounded to zero")]
882    fn make_qty_from_decimal_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
883        currency_pair_btcusdt.make_qty_from_decimal(dec!(0.0000001), None);
884    }
885
886    #[rstest]
887    #[case(Price::from("10000"), "10000.00")]
888    #[case(Price::from("10000.0000"), "10000.00")]
889    fn try_normalize_price_rewrites_grid_aligned_values(
890        currency_pair_btcusdt: CurrencyPair,
891        #[case] input: Price,
892        #[case] expected: &str,
893    ) {
894        let normalized = currency_pair_btcusdt.try_normalize_price(input).unwrap();
895
896        assert_eq!(normalized.raw, input.raw);
897        assert_eq!(
898            normalized.precision,
899            currency_pair_btcusdt.price_precision()
900        );
901        assert_eq!(normalized, Price::from(expected));
902    }
903
904    #[rstest]
905    fn try_normalize_price_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
906        let error = currency_pair_btcusdt
907            .try_normalize_price(Price::from("10000.001"))
908            .unwrap_err();
909
910        assert!(matches!(
911            error,
912            CorrectnessError::PredicateViolation { ref message }
913                if message.contains("requires rounding to instrument price precision")
914        ));
915    }
916
917    #[rstest]
918    #[case(Price::from_raw(PRICE_UNDEF, 0), "PRICE_UNDEF")]
919    #[case(Price::from_raw(PRICE_ERROR, 0), "PRICE_ERROR")]
920    #[case(ERROR_PRICE, "ERROR_PRICE")]
921    fn try_normalize_price_rejects_sentinel_values(
922        currency_pair_btcusdt: CurrencyPair,
923        #[case] input: Price,
924        #[case] expected_value: &str,
925    ) {
926        let error = currency_pair_btcusdt
927            .try_normalize_price(input)
928            .unwrap_err();
929
930        match error {
931            CorrectnessError::InvalidValue {
932                param,
933                value,
934                type_name,
935            } => {
936                assert_eq!(param, "price");
937                assert_eq!(value, expected_value);
938                assert_eq!(type_name, "`Price`");
939            }
940            _ => panic!("expected invalid price error, was {error}"),
941        }
942    }
943
944    #[rstest]
945    #[case(Price::from("-10000"), Some(Price::from("-10000.00")))]
946    #[case(Price::from("-10000.001"), None)]
947    fn try_normalize_price_handles_negative_values(
948        currency_pair_btcusdt: CurrencyPair,
949        #[case] input: Price,
950        #[case] expected: Option<Price>,
951    ) {
952        let normalized = currency_pair_btcusdt.try_normalize_price(input).ok();
953
954        assert_eq!(normalized, expected);
955    }
956
957    #[rstest]
958    fn try_normalize_price_rejects_sub_increment_value() {
959        let instrument = CurrencyPair::new(
960            InstrumentId::from("TEST.VENUE"),
961            Symbol::from("TEST"),
962            Currency::from("BTC"),
963            Currency::from("USD"),
964            2,
965            2,
966            Price::from("0.50"),
967            Quantity::from("0.01"),
968            None,
969            None,
970            None,
971            None,
972            None,
973            None,
974            None,
975            None,
976            None,
977            None,
978            None,
979            None,
980            None,
981            None, // info
982            UnixNanos::default(),
983            UnixNanos::default(),
984        );
985
986        assert_eq!(
987            instrument.try_normalize_price(Price::from("1.500")),
988            Ok(Price::from("1.50"))
989        );
990        let error = instrument
991            .try_normalize_price(Price::from("1.20"))
992            .unwrap_err();
993
994        assert!(matches!(
995            error,
996            CorrectnessError::PredicateViolation { ref message }
997                if message.contains("not aligned to price increment")
998        ));
999    }
1000
1001    #[rstest]
1002    #[case(Quantity::from("1"), "1.000000")]
1003    #[case(Quantity::from("1.0000000"), "1.000000")]
1004    fn try_normalize_qty_rewrites_grid_aligned_values(
1005        currency_pair_btcusdt: CurrencyPair,
1006        #[case] input: Quantity,
1007        #[case] expected: &str,
1008    ) {
1009        let normalized = currency_pair_btcusdt.try_normalize_qty(input).unwrap();
1010
1011        assert_eq!(normalized.raw, input.raw);
1012        assert_eq!(normalized.precision, currency_pair_btcusdt.size_precision());
1013        assert_eq!(normalized, Quantity::from(expected));
1014    }
1015
1016    #[rstest]
1017    fn try_normalize_qty_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
1018        let error = currency_pair_btcusdt
1019            .try_normalize_qty(Quantity::from("1.0000001"))
1020            .unwrap_err();
1021
1022        assert!(matches!(
1023            error,
1024            CorrectnessError::PredicateViolation { ref message }
1025                if message.contains("requires rounding to instrument size precision")
1026        ));
1027    }
1028
1029    #[rstest]
1030    fn try_normalize_qty_rejects_undefined_value(currency_pair_btcusdt: CurrencyPair) {
1031        let error = currency_pair_btcusdt
1032            .try_normalize_qty(Quantity::from_raw(QUANTITY_UNDEF, 0))
1033            .unwrap_err();
1034
1035        match error {
1036            CorrectnessError::InvalidValue {
1037                param,
1038                value,
1039                type_name,
1040            } => {
1041                assert_eq!(param, "quantity");
1042                assert_eq!(value, "QUANTITY_UNDEF");
1043                assert_eq!(type_name, "`Quantity`");
1044            }
1045            _ => panic!("expected invalid quantity error, was {error}"),
1046        }
1047    }
1048
1049    #[cfg(feature = "defi")]
1050    #[rstest]
1051    fn try_normalize_values_reject_mixed_raw_scales() {
1052        let defi_precision = 18;
1053        let price_increment = Price::from_raw(PriceRaw::from(5) * PriceRaw::pow(10, 17), 18);
1054        let size_increment =
1055            Quantity::from_raw(QuantityRaw::from(5_u8) * QuantityRaw::pow(10, 17), 18);
1056        let instrument = CurrencyPair::new(
1057            InstrumentId::from("TEST.VENUE"),
1058            Symbol::from("TEST"),
1059            Currency::from("BTC"),
1060            Currency::from("USD"),
1061            defi_precision,
1062            defi_precision,
1063            price_increment,
1064            size_increment,
1065            None,
1066            None,
1067            None,
1068            None,
1069            None,
1070            None,
1071            None,
1072            None,
1073            None,
1074            None,
1075            None,
1076            None,
1077            None,
1078            None, // info
1079            UnixNanos::default(),
1080            UnixNanos::default(),
1081        );
1082        let fixed_scale = u32::from(FIXED_PRECISION);
1083        let fixed_price = Price::from_raw(
1084            PriceRaw::pow(10, fixed_scale) * PriceRaw::from(100),
1085            FIXED_PRECISION,
1086        );
1087        let fixed_qty = Quantity::from_raw(
1088            QuantityRaw::pow(10, fixed_scale) * QuantityRaw::from(100_u8),
1089            FIXED_PRECISION,
1090        );
1091
1092        let price_error = instrument.try_normalize_price(fixed_price).unwrap_err();
1093        let qty_error = instrument.try_normalize_qty(fixed_qty).unwrap_err();
1094
1095        assert!(matches!(
1096            price_error,
1097            CorrectnessError::PredicateViolation { ref message }
1098                if message.contains("raw scale does not match instrument price precision")
1099        ));
1100        assert!(matches!(
1101            qty_error,
1102            CorrectnessError::PredicateViolation { ref message }
1103                if message.contains("raw scale does not match instrument size precision")
1104        ));
1105    }
1106
1107    #[rstest]
1108    fn try_normalize_qty_rejects_sub_increment_value() {
1109        let instrument = CurrencyPair::new(
1110            InstrumentId::from("TEST.VENUE"),
1111            Symbol::from("TEST"),
1112            Currency::from("BTC"),
1113            Currency::from("USD"),
1114            2,
1115            2,
1116            Price::from("0.01"),
1117            Quantity::from("0.50"),
1118            None,
1119            None,
1120            None,
1121            None,
1122            None,
1123            None,
1124            None,
1125            None,
1126            None,
1127            None,
1128            None,
1129            None,
1130            None,
1131            None, // info
1132            UnixNanos::default(),
1133            UnixNanos::default(),
1134        );
1135
1136        assert_eq!(
1137            instrument.try_normalize_qty(Quantity::from("1.500")),
1138            Ok(Quantity::from("1.50"))
1139        );
1140        let error = instrument
1141            .try_normalize_qty(Quantity::from("1.20"))
1142            .unwrap_err();
1143
1144        assert!(matches!(
1145            error,
1146            CorrectnessError::PredicateViolation { ref message }
1147                if message.contains("not aligned to size increment")
1148        ));
1149    }
1150
1151    #[rstest]
1152    #[should_panic(expected = "value rounded to zero")]
1153    fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
1154        currency_pair_btcusdt.make_qty(1e-12, None);
1155    }
1156
1157    #[rstest]
1158    fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
1159        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1160        let price = currency_pair_btcusdt.make_price(10_000.0);
1161        let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
1162        let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
1163        assert_eq!(notional, expected);
1164    }
1165
1166    #[rstest]
1167    fn currency_pair_is_not_quanto(currency_pair_btcusdt: CurrencyPair) {
1168        assert!(!currency_pair_btcusdt.is_quanto());
1169        assert_eq!(currency_pair_btcusdt.cost_currency(), Currency::USDT());
1170    }
1171
1172    #[rstest]
1173    fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
1174        let start = 10_000.123_4;
1175        let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
1176        let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
1177        assert!(bid_1 < bid_0);
1178        let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
1179        assert_eq!(asks.len(), 3);
1180        assert!(asks[0] > bid_0);
1181    }
1182
1183    #[rstest]
1184    fn tick_navigation_uses_tick_scheme() {
1185        let instrument = CurrencyPair::new(
1186            InstrumentId::from("TEST.VENUE"),
1187            Symbol::from("TEST"),
1188            Currency::from("BTC"),
1189            Currency::from("USD"),
1190            2,
1191            2,
1192            Price::new(0.01, 2),
1193            Quantity::from("0.01"),
1194            None,
1195            None,
1196            None,
1197            None,
1198            None,
1199            None,
1200            None,
1201            None,
1202            None,
1203            None,
1204            None,
1205            None,
1206            Some(Ustr::from("FIXED_PRECISION_1")),
1207            None,
1208            UnixNanos::default(),
1209            UnixNanos::default(),
1210        );
1211
1212        assert_eq!(
1213            instrument.tick_scheme(),
1214            Some(Ustr::from("FIXED_PRECISION_1"))
1215        );
1216        assert_eq!(instrument.next_bid_price(1.23, 0), Some(Price::new(1.2, 2)));
1217        assert_eq!(instrument.next_ask_price(1.23, 0), Some(Price::new(1.3, 2)));
1218    }
1219
1220    #[rstest]
1221    #[case("BOGUS")]
1222    #[case("FIXED_PRECISION_99")]
1223    fn invalid_tick_scheme_returns_error(#[case] tick_scheme: &str) {
1224        let err = CurrencyPair::new_checked(
1225            InstrumentId::from("TEST.VENUE"),
1226            Symbol::from("TEST"),
1227            Currency::from("BTC"),
1228            Currency::from("USD"),
1229            2,
1230            2,
1231            Price::new(0.01, 2),
1232            Quantity::from("0.01"),
1233            None,
1234            None,
1235            None,
1236            None,
1237            None,
1238            None,
1239            None,
1240            None,
1241            None,
1242            None,
1243            None,
1244            None,
1245            Some(Ustr::from(tick_scheme)),
1246            None,
1247            UnixNanos::default(),
1248            UnixNanos::default(),
1249        )
1250        .expect_err("invalid tick scheme must fail");
1251
1252        assert!(
1253            err.to_string()
1254                .contains("tick_scheme not found in tick schemes"),
1255            "{err}"
1256        );
1257    }
1258
1259    #[rstest]
1260    #[should_panic(expected = "'margin_init' not positive")]
1261    fn validate_negative_margin_init() {
1262        let size_increment = Quantity::new(0.01, 2);
1263        let multiplier = Quantity::new(1.0, 0);
1264
1265        validate_instrument_common(
1266            2,
1267            2,              // size_precision
1268            size_increment, // size_increment
1269            multiplier,     // multiplier
1270            dec!(-0.01),    // margin_init
1271            dec!(0.01),     // margin_maint
1272            None,           // price_increment
1273            None,           // lot_size
1274            None,           // max_quantity
1275            None,           // min_quantity
1276            None,           // max_notional
1277            None,           // min_notional
1278            None,           // max_price
1279            None,           // min_price
1280        )
1281        .expect_display(FAILED);
1282    }
1283
1284    #[rstest]
1285    #[should_panic(expected = "'margin_maint' not positive")]
1286    fn validate_negative_margin_maint() {
1287        let size_increment = Quantity::new(0.01, 2);
1288        let multiplier = Quantity::new(1.0, 0);
1289
1290        validate_instrument_common(
1291            2,
1292            2,              // size_precision
1293            size_increment, // size_increment
1294            multiplier,     // multiplier
1295            dec!(0.01),     // margin_init
1296            dec!(-0.01),    // margin_maint
1297            None,           // price_increment
1298            None,           // lot_size
1299            None,           // max_quantity
1300            None,           // min_quantity
1301            None,           // max_notional
1302            None,           // min_notional
1303            None,           // max_price
1304            None,           // min_price
1305        )
1306        .expect_display(FAILED);
1307    }
1308
1309    #[rstest]
1310    #[should_panic(expected = "'margin_init' not positive")]
1311    fn validate_negative_max_qty() {
1312        let quantity = Quantity::new(0.0, 0);
1313        validate_instrument_common(
1314            2,
1315            2,
1316            Quantity::new(0.01, 2),
1317            Quantity::new(1.0, 0),
1318            dec!(0),
1319            dec!(0),
1320            None,
1321            None,
1322            Some(quantity),
1323            None,
1324            None,
1325            None,
1326            None,
1327            None,
1328        )
1329        .expect_display(FAILED);
1330    }
1331
1332    #[rstest]
1333    fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
1334        let price = currency_pair_ethusdt.make_price(-123.456_789);
1335        assert!(price.as_f64() < 0.0);
1336    }
1337
1338    #[rstest]
1339    fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
1340        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1341        let price = currency_pair_btcusdt.make_price(10_000.0);
1342        let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
1343        assert_eq!(base.to_string(), "0.000200");
1344    }
1345
1346    #[rstest]
1347    fn base_quantity_zero_last_price_returns_error(currency_pair_btcusdt: CurrencyPair) {
1348        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1349        let error = currency_pair_btcusdt
1350            .try_calculate_base_quantity(quantity, Price::new(0.0, 2))
1351            .unwrap_err();
1352        assert!(
1353            error.to_string().contains("`last_price` was zero"),
1354            "{error}"
1355        );
1356    }
1357
1358    #[rstest]
1359    #[case(f64::NAN)]
1360    #[case(f64::INFINITY)]
1361    #[case(1e30)] // Finite but not representable as a Decimal
1362    fn make_price_invalid_value_returns_error(
1363        currency_pair_btcusdt: CurrencyPair,
1364        #[case] value: f64,
1365    ) {
1366        let error = currency_pair_btcusdt.try_make_price(value).unwrap_err();
1367        assert!(
1368            error.to_string().contains("invalid `value` for make_price"),
1369            "{error}"
1370        );
1371    }
1372
1373    #[rstest]
1374    fn make_qty_invalid_value_returns_error(currency_pair_btcusdt: CurrencyPair) {
1375        let error = currency_pair_btcusdt
1376            .try_make_qty(f64::NAN, None)
1377            .unwrap_err();
1378        assert!(
1379            error.to_string().contains("invalid `value` for make_qty"),
1380            "{error}"
1381        );
1382    }
1383
1384    #[rstest]
1385    fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1386        let start = 10_000.0;
1387        let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
1388        assert_eq!(bids.len(), 5);
1389        for i in 1..bids.len() {
1390            assert!(bids[i] < bids[i - 1]);
1391        }
1392    }
1393
1394    #[rstest]
1395    fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1396        let start = 10_000.0;
1397        let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
1398        assert_eq!(asks.len(), 5);
1399        for i in 1..asks.len() {
1400            assert!(asks[i] > asks[i - 1]);
1401        }
1402    }
1403
1404    #[rstest]
1405    #[should_panic(expected = "'margin_init' not positive")]
1406    fn validate_price_increment_precision_mismatch() {
1407        let size_increment = Quantity::new(0.01, 2);
1408        let multiplier = Quantity::new(1.0, 0);
1409        let price_increment = Price::new(0.001, 3);
1410        validate_instrument_common(
1411            2,
1412            2,
1413            size_increment,
1414            multiplier,
1415            dec!(0),
1416            dec!(0),
1417            Some(price_increment),
1418            None,
1419            None,
1420            None,
1421            None,
1422            None,
1423            None,
1424            None,
1425        )
1426        .expect_display(FAILED);
1427    }
1428
1429    #[rstest]
1430    #[should_panic(expected = "'margin_init' not positive")]
1431    fn validate_min_price_exceeds_max_price() {
1432        let size_increment = Quantity::new(0.01, 2);
1433        let multiplier = Quantity::new(1.0, 0);
1434        let min_price = Price::new(10.0, 2);
1435        let max_price = Price::new(5.0, 2);
1436        validate_instrument_common(
1437            2,
1438            2,
1439            size_increment,
1440            multiplier,
1441            dec!(0),
1442            dec!(0),
1443            None,
1444            None,
1445            None,
1446            None,
1447            None,
1448            None,
1449            Some(max_price),
1450            Some(min_price),
1451        )
1452        .expect_display(FAILED);
1453    }
1454
1455    #[rstest]
1456    fn validate_instrument_common_ok() {
1457        let res = validate_instrument_common(
1458            2,
1459            4,
1460            Quantity::new(0.0001, 4),
1461            Quantity::new(1.0, 0),
1462            dec!(0.02),
1463            dec!(0.01),
1464            Some(Price::new(0.01, 2)),
1465            None,
1466            None,
1467            None,
1468            None,
1469            None,
1470            None,
1471            None,
1472        );
1473        assert!(matches!(res, Ok(())));
1474    }
1475
1476    #[rstest]
1477    #[should_panic(expected = "not in range")]
1478    fn validate_multiple_errors() {
1479        validate_instrument_common(
1480            2,
1481            2,
1482            Quantity::new(-0.01, 2),
1483            Quantity::new(0.0, 0),
1484            dec!(0),
1485            dec!(0),
1486            None,
1487            None,
1488            None,
1489            None,
1490            None,
1491            None,
1492            None,
1493            None,
1494        )
1495        .expect_display(FAILED);
1496    }
1497
1498    #[rstest]
1499    #[case(1.234_999_9, false, "1.235000")]
1500    #[case(1.234_999_9, true, "1.234999")]
1501    fn make_qty_boundary(
1502        currency_pair_btcusdt: CurrencyPair,
1503        #[case] input: f64,
1504        #[case] round_down: bool,
1505        #[case] expected: &str,
1506    ) {
1507        let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
1508        assert_eq!(quantity.to_string(), expected);
1509    }
1510
1511    #[rstest]
1512    #[case(1.234_999, 1.23)]
1513    #[case(1.235, 1.24)]
1514    #[case(1.235_001, 1.24)]
1515    fn make_price_rounding_parity(
1516        currency_pair_btcusdt: CurrencyPair,
1517        #[case] input: f64,
1518        #[case] expected: f64,
1519    ) {
1520        let price = currency_pair_btcusdt.make_price(input);
1521        assert!((price.as_f64() - expected).abs() < 1e-9);
1522    }
1523
1524    #[rstest]
1525    fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
1526        let rounding_precision = std::cmp::min(
1527            currency_pair_btcusdt.price_precision(),
1528            currency_pair_btcusdt.min_price_increment_precision(),
1529        );
1530        let step = 10f64.powi(-i32::from(rounding_precision));
1531        let base_even_multiple = 42.0;
1532        let base_value = step * base_even_multiple;
1533        let delta = step / 2000.0;
1534        let value_below = base_value + 0.5 * step - delta;
1535        let value_exact = base_value + 0.5 * step;
1536        let value_above = base_value + 0.5 * step + delta;
1537        let price_below = currency_pair_btcusdt.make_price(value_below);
1538        let price_exact = currency_pair_btcusdt.make_price(value_exact);
1539        let price_above = currency_pair_btcusdt.make_price(value_above);
1540        assert_eq!(price_below, price_exact);
1541        assert_ne!(price_exact, price_above);
1542    }
1543
1544    #[rstest]
1545    #[case(dec!(1.234999), dec!(1.23))]
1546    #[case(dec!(1.235), dec!(1.24))]
1547    #[case(dec!(1.235001), dec!(1.24))]
1548    #[case(dec!(10000.0), dec!(10000.0))]
1549    fn make_price_from_decimal_matches_f64_path(
1550        currency_pair_btcusdt: CurrencyPair,
1551        #[case] value: Decimal,
1552        #[case] expected: Decimal,
1553    ) {
1554        let from_decimal = currency_pair_btcusdt.make_price_from_decimal(value);
1555        let from_f64 = currency_pair_btcusdt.make_price(value.to_string().parse::<f64>().unwrap());
1556        assert_eq!(from_decimal, from_f64);
1557        assert_eq!(from_decimal.as_decimal(), expected);
1558    }
1559
1560    #[rstest]
1561    fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
1562        assert!(ethbtc_quanto.is_quanto());
1563    }
1564
1565    #[rstest]
1566    fn notional_quanto(ethbtc_quanto: CryptoFuture) {
1567        let quantity = ethbtc_quanto.make_qty(5.0, None);
1568        let price = ethbtc_quanto.make_price(0.036);
1569        let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
1570        let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
1571        assert_eq!(notional, expected);
1572    }
1573
1574    #[rstest]
1575    #[case("USD", "BUSD")]
1576    #[case("USD", "FDUSD")]
1577    #[case("USD", "pUSD")]
1578    #[case("USD", "TUSD")]
1579    #[case("USD", "USD")]
1580    #[case("USD", "USDC")]
1581    #[case("USD", "USDC.e")]
1582    #[case("USD", "USDP")]
1583    #[case("USD", "USDT")]
1584    #[case("BUSD", "USD")]
1585    #[case("FDUSD", "USD")]
1586    #[case("pUSD", "USD")]
1587    #[case("TUSD", "USD")]
1588    #[case("USDC", "USD")]
1589    #[case("USDC.e", "USD")]
1590    #[case("USDP", "USD")]
1591    #[case("USDT", "USD")]
1592    fn usd_equivalent_settlement_is_not_quanto(
1593        #[case] quote_currency_code: &str,
1594        #[case] settlement_currency_code: &str,
1595    ) {
1596        let quote_currency =
1597            Currency::try_from_str(quote_currency_code).expect("quote currency must exist");
1598        let settlement_currency = Currency::try_from_str(settlement_currency_code)
1599            .expect("settlement currency must exist");
1600        let instrument = crypto_future_with_quote_settlement(quote_currency, settlement_currency);
1601        let quantity = instrument.make_qty(5.0, None);
1602        let price = instrument.make_price(1000.0);
1603        let notional = instrument.calculate_notional_value(quantity, price, None);
1604
1605        assert!(!instrument.is_quanto());
1606        assert_eq!(instrument.cost_currency(), quote_currency);
1607        assert_eq!(notional, Money::new(5000.0, quote_currency));
1608    }
1609
1610    #[rstest]
1611    fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
1612        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1613        let price = xbtusd_inverse_perp.make_price(50_000.0);
1614        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
1615        let expected = Money::new(
1616            100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
1617            xbtusd_inverse_perp.base_currency().unwrap(),
1618        );
1619        assert_eq!(notional, expected);
1620    }
1621
1622    #[rstest]
1623    fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
1624        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1625        let price = xbtusd_inverse_perp.make_price(50_000.0);
1626        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
1627        let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
1628        assert_eq!(notional, expected);
1629    }
1630
1631    #[rstest]
1632    fn try_notional_inverse_zero_price_returns_error(xbtusd_inverse_perp: CryptoPerpetual) {
1633        let result = xbtusd_inverse_perp.try_calculate_notional_value(
1634            xbtusd_inverse_perp.make_qty(100.0, None),
1635            Price::new(0.0, 1),
1636            Some(false),
1637        );
1638
1639        assert_eq!(
1640            result.unwrap_err().to_string(),
1641            "price must be positive for inverse notional valuation"
1642        );
1643    }
1644
1645    #[rstest]
1646    fn try_notional_unrepresentable_money_returns_error(currency_pair_btcusdt: CurrencyPair) {
1647        let result = currency_pair_btcusdt.try_calculate_notional_value(
1648            Quantity::from("100000000"),
1649            Price::from("100000000"),
1650            None,
1651        );
1652
1653        assert!(result.is_err());
1654    }
1655
1656    #[rstest]
1657    fn try_notional_decimal_overflow_returns_error() {
1658        let result = try_notional_value(
1659            Quantity::from("9000000000"),
1660            Price::from("9000000000"),
1661            Quantity::from("9000000000"),
1662            false,
1663            false,
1664            Currency::USD(),
1665        );
1666
1667        assert_eq!(
1668            result.unwrap_err().to_string(),
1669            "notional calculation overflow"
1670        );
1671    }
1672
1673    #[rstest]
1674    #[should_panic(expected = "'margin_init' not positive")]
1675    fn validate_non_positive_max_price() {
1676        let size_increment = Quantity::new(0.01, 2);
1677        let multiplier = Quantity::new(1.0, 0);
1678        let max_price = Price::new(0.0, 2);
1679        validate_instrument_common(
1680            2,
1681            2,
1682            size_increment,
1683            multiplier,
1684            dec!(0),
1685            dec!(0),
1686            None,
1687            None,
1688            None,
1689            None,
1690            None,
1691            None,
1692            Some(max_price),
1693            None,
1694        )
1695        .expect_display(FAILED);
1696    }
1697
1698    #[rstest]
1699    #[should_panic(expected = "'margin_init' not positive")]
1700    fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1701        let size_increment = Quantity::new(0.01, 2);
1702        let multiplier = Quantity::new(1.0, 0);
1703        let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1704        validate_instrument_common(
1705            2,
1706            2,
1707            size_increment,
1708            multiplier,
1709            dec!(0),
1710            dec!(0),
1711            None,
1712            None,
1713            None,
1714            None,
1715            Some(max_notional),
1716            None,
1717            None,
1718            None,
1719        )
1720        .expect_display(FAILED);
1721    }
1722
1723    #[rstest]
1724    #[should_panic(expected = "'margin_init' not positive")]
1725    fn validate_price_increment_min_price_precision_mismatch() {
1726        let size_increment = Quantity::new(0.01, 2);
1727        let multiplier = Quantity::new(1.0, 0);
1728        let price_increment = Price::new(0.01, 2);
1729        let min_price = Price::new(1.0, 3);
1730        validate_instrument_common(
1731            2,
1732            2,
1733            size_increment,
1734            multiplier,
1735            dec!(0),
1736            dec!(0),
1737            Some(price_increment),
1738            None,
1739            None,
1740            None,
1741            None,
1742            None,
1743            None,
1744            Some(min_price),
1745        )
1746        .expect_display(FAILED);
1747    }
1748
1749    #[rstest]
1750    #[should_panic(expected = "'margin_init' not positive")]
1751    fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1752        let size_increment = Quantity::new(0.01, 2);
1753        let multiplier = Quantity::new(1.0, 0);
1754        let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1755        let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1756        validate_instrument_common(
1757            2,
1758            2,
1759            size_increment,
1760            multiplier,
1761            dec!(0),
1762            dec!(0),
1763            None,
1764            None,
1765            None,
1766            None,
1767            Some(max_notional),
1768            Some(min_notional),
1769            None,
1770            None,
1771        )
1772        .expect_display(FAILED);
1773    }
1774
1775    #[rstest]
1776    #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1777    #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1778    #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1779    #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1780    #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1781    #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1782    #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1783    #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1784    #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1785    fn base_qty_rounding(
1786        currency_pair_btcusdt: CurrencyPair,
1787        #[case] q: Decimal,
1788        #[case] px: Decimal,
1789        #[case] expected: f64,
1790    ) {
1791        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1792        let price = Price::new(px.to_f64().unwrap(), 8);
1793        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1794        assert!((base.as_f64() - expected).abs() < 1e-9);
1795    }
1796
1797    proptest! {
1798        #[rstest]
1799        fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1800            let instrument = currency_pair_btcusdt();
1801            let price = instrument.make_price(input);
1802            prop_assert!(price.as_f64().is_finite());
1803            let quantity = instrument.make_qty(input, None);
1804            prop_assert!(quantity.as_f64().is_finite());
1805        }
1806    }
1807
1808    #[rstest]
1809    fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1810        if let Some(max_price) = currency_pair_btcusdt.max_price() {
1811            assert!(
1812                currency_pair_btcusdt
1813                    .next_ask_price(max_price.as_f64(), 1)
1814                    .is_none()
1815            );
1816        }
1817    }
1818
1819    #[rstest]
1820    fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1821        if let Some(max_price) = currency_pair_ethusdt.max_price() {
1822            assert!(
1823                currency_pair_ethusdt
1824                    .next_ask_price(max_price.as_f64(), 1)
1825                    .is_none()
1826            );
1827        }
1828    }
1829
1830    #[rstest]
1831    fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1832        if let Some(min_price) = currency_pair_btcusdt.min_price() {
1833            assert!(
1834                currency_pair_btcusdt
1835                    .next_bid_price(min_price.as_f64(), 1)
1836                    .is_none()
1837            );
1838        }
1839    }
1840
1841    #[rstest]
1842    fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1843        if let Some(min_price) = currency_pair_ethusdt.min_price() {
1844            assert!(
1845                currency_pair_ethusdt
1846                    .next_bid_price(min_price.as_f64(), 1)
1847                    .is_none()
1848            );
1849        }
1850    }
1851
1852    #[rstest]
1853    fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1854        if let Some(max_price) = ethbtc_quanto.max_price() {
1855            assert!(
1856                ethbtc_quanto
1857                    .next_ask_price(max_price.as_f64(), 1)
1858                    .is_none()
1859            );
1860        }
1861    }
1862
1863    #[rstest]
1864    #[case(0.999_999, false)]
1865    #[case(0.999_999, true)]
1866    #[case(1.000_000_1, false)]
1867    #[case(1.000_000_1, true)]
1868    #[case(1.234_5, false)]
1869    #[case(1.234_5, true)]
1870    #[case(2.345_5, false)]
1871    #[case(2.345_5, true)]
1872    #[case(0.000_999_999, false)]
1873    #[case(0.000_999_999, true)]
1874    fn quantity_rounding_grid(
1875        currency_pair_btcusdt: CurrencyPair,
1876        #[case] input: f64,
1877        #[case] round_down: bool,
1878    ) {
1879        let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1880        assert!(qty.as_f64().is_finite());
1881    }
1882
1883    #[rstest]
1884    fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1885        let size_increment = Quantity::new(0.01, 2);
1886        let multiplier = Quantity::new(1.0, 0);
1887        let price_increment = Price::new(0.01, 2);
1888        let max_price = Price::new(1.0, 3);
1889        let res = validate_instrument_common(
1890            2,
1891            2,
1892            size_increment,
1893            multiplier,
1894            dec!(0),
1895            dec!(0),
1896            Some(price_increment),
1897            None,
1898            None,
1899            None,
1900            None,
1901            None,
1902            Some(max_price),
1903            None,
1904        );
1905        assert!(res.is_err());
1906    }
1907
1908    #[rstest]
1909    #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1910    #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1911    #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1912    #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1913    #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1914    #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1915    #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1916    #[case::dp16(
1917        Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1918        Decimal::new(2, 0),
1919        500.0
1920    )]
1921    #[case::dp17(
1922        Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1923        Decimal::new(2, 0),
1924        500.0
1925    )]
1926    fn base_qty_rounding_high_dp(
1927        currency_pair_btcusdt: CurrencyPair,
1928        #[case] q: Decimal,
1929        #[case] px: Decimal,
1930        #[case] expected: f64,
1931    ) {
1932        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1933        let price = Price::new(px.to_f64().unwrap(), 8);
1934        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1935        assert!((base.as_f64() - expected).abs() < 1e-9);
1936    }
1937
1938    #[rstest]
1939    fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1940        let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1941        assert!(check_positive_money(money, "money").is_ok());
1942    }
1943
1944    #[rstest]
1945    #[should_panic(expected = "NotPositive")]
1946    fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1947        let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1948        check_positive_money(money, "money").unwrap();
1949    }
1950
1951    #[rstest]
1952    #[should_panic(expected = "NotPositive")]
1953    fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1954        let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1955        check_positive_money(money, "money").unwrap();
1956    }
1957
1958    fn crypto_future_with_quote_settlement(
1959        quote_currency: Currency,
1960        settlement_currency: Currency,
1961    ) -> CryptoFuture {
1962        CryptoFuture::new(
1963            InstrumentId::from("ETHUSD-QUANTO-TEST.BINANCE"),
1964            Symbol::from("ETHUSD-QUANTO-TEST"),
1965            Currency::ETH(),
1966            quote_currency,
1967            settlement_currency,
1968            false,
1969            0.into(),
1970            0.into(),
1971            2,
1972            0,
1973            Price::from("0.01"),
1974            Quantity::from("1"),
1975            None,
1976            None,
1977            None,
1978            None,
1979            None,
1980            None,
1981            None,
1982            None,
1983            None,
1984            None,
1985            None,
1986            None,
1987            None,
1988            None,
1989            0.into(),
1990            0.into(),
1991        )
1992    }
1993
1994    #[rstest]
1995    fn make_price_with_trailing_zeros_in_increment() {
1996        // Test instrument with price_increment 0.50 (precision 2, but min_increment_precision 1)
1997        // This verifies that trailing zeros in price_increment are handled correctly
1998        let instrument = CurrencyPair::new(
1999            InstrumentId::from("TEST.VENUE"),
2000            Symbol::from("TEST"),
2001            Currency::from("BTC"),
2002            Currency::from("USD"),
2003            2,                   // price_precision
2004            2,                   // size_precision
2005            Price::new(0.50, 2), // price_increment with trailing zero
2006            Quantity::from("0.01"),
2007            None,
2008            None,
2009            None,
2010            None,
2011            None,
2012            None,
2013            None,
2014            None,
2015            None,
2016            None,
2017            None,
2018            None,
2019            None,
2020            None, // info
2021            UnixNanos::default(),
2022            UnixNanos::default(),
2023        );
2024
2025        // Verify min_increment_precision is 1 (ignoring trailing zero)
2026        assert_eq!(instrument.min_price_increment_precision(), 1);
2027
2028        // Test that make_price rounds to min_increment_precision (1)
2029        // 1.234 should round to 1.2 (not 1.23)
2030        let price = instrument.make_price(1.234);
2031        assert_eq!(price.as_f64(), 1.2);
2032
2033        // 1.25 should round to 1.2 (half-even rounding)
2034        let price = instrument.make_price(1.25);
2035        assert_eq!(price.as_f64(), 1.2);
2036
2037        // 1.35 should round to 1.4 (half-even rounding)
2038        let price = instrument.make_price(1.35);
2039        assert_eq!(price.as_f64(), 1.4);
2040
2041        // But output precision should still be 2
2042        assert_eq!(price.precision, 2);
2043    }
2044
2045    #[rstest]
2046    fn make_qty_with_trailing_zeros_in_increment() {
2047        // Test instrument with size_increment 0.50 (precision 2, but min_increment_precision 1)
2048        let instrument = CurrencyPair::new(
2049            InstrumentId::from("TEST.VENUE"),
2050            Symbol::from("TEST"),
2051            Currency::from("BTC"),
2052            Currency::from("USD"),
2053            2, // price_precision
2054            2, // size_precision
2055            Price::new(0.01, 2),
2056            Quantity::new(0.50, 2), // size_increment with trailing zero
2057            None,
2058            None,
2059            None,
2060            None,
2061            None,
2062            None,
2063            None,
2064            None,
2065            None,
2066            None,
2067            None,
2068            None,
2069            None,
2070            None, // info
2071            UnixNanos::default(),
2072            UnixNanos::default(),
2073        );
2074
2075        // Verify min_increment_precision is 1 (ignoring trailing zero)
2076        assert_eq!(instrument.min_size_increment_precision(), 1);
2077
2078        // Test that make_qty rounds to min_increment_precision (1)
2079        // 1.234 should round to 1.2 (not 1.23)
2080        let qty = instrument.make_qty(1.234, None);
2081        assert_eq!(qty.as_f64(), 1.2);
2082
2083        // 1.25 should round to 1.2 (half-even rounding)
2084        let qty = instrument.make_qty(1.25, None);
2085        assert_eq!(qty.as_f64(), 1.2);
2086
2087        // 1.35 should round to 1.4 (half-even rounding)
2088        let qty = instrument.make_qty(1.35, None);
2089        assert_eq!(qty.as_f64(), 1.4);
2090
2091        // But output precision should still be 2
2092        assert_eq!(qty.precision, 2);
2093
2094        // Test round_down option
2095        let qty = instrument.make_qty(1.99, Some(true));
2096        assert_eq!(qty.as_f64(), 1.9);
2097    }
2098
2099    #[rstest]
2100    #[case(InstrumentClass::Future, true)]
2101    #[case(InstrumentClass::FuturesSpread, true)]
2102    #[case(InstrumentClass::Option, true)]
2103    #[case(InstrumentClass::OptionSpread, true)]
2104    #[case(InstrumentClass::Spot, false)]
2105    #[case(InstrumentClass::Swap, false)]
2106    #[case(InstrumentClass::Forward, false)]
2107    #[case(InstrumentClass::Cfd, false)]
2108    #[case(InstrumentClass::Bond, false)]
2109    #[case(InstrumentClass::Warrant, false)]
2110    #[case(InstrumentClass::SportsBetting, false)]
2111    #[case(InstrumentClass::BinaryOption, false)]
2112    fn test_instrument_class_has_expiration(
2113        #[case] instrument_class: InstrumentClass,
2114        #[case] expected: bool,
2115    ) {
2116        assert_eq!(instrument_class.has_expiration(), expected);
2117    }
2118}