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 crypto_future;
22pub mod crypto_option;
23pub mod crypto_perpetual;
24pub mod currency_pair;
25pub mod equity;
26pub mod futures_contract;
27pub mod futures_spread;
28pub mod option_contract;
29pub mod option_spread;
30pub mod synthetic;
31
32#[cfg(any(test, feature = "stubs"))]
33pub mod stubs;
34
35use std::{fmt::Display, str::FromStr};
36
37use enum_dispatch::enum_dispatch;
38use nautilus_core::{
39    UnixNanos,
40    correctness::{check_equal_u8, check_positive_decimal, check_predicate_true},
41    parsing::min_increment_precision_from_str,
42};
43use rust_decimal::{Decimal, RoundingStrategy};
44use rust_decimal_macros::dec;
45use ustr::Ustr;
46
47pub use crate::instruments::{
48    any::InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption,
49    crypto_future::CryptoFuture, crypto_option::CryptoOption, crypto_perpetual::CryptoPerpetual,
50    currency_pair::CurrencyPair, equity::Equity, futures_contract::FuturesContract,
51    futures_spread::FuturesSpread, option_contract::OptionContract, option_spread::OptionSpread,
52    synthetic::SyntheticInstrument,
53};
54use crate::{
55    enums::{AssetClass, InstrumentClass, OptionKind},
56    identifiers::{InstrumentId, Symbol, Venue},
57    types::{
58        Currency, Money, Price, Quantity, money::check_positive_money, price::check_positive_price,
59        quantity::check_positive_quantity,
60    },
61};
62
63#[allow(clippy::missing_errors_doc, clippy::too_many_arguments)]
64pub fn validate_instrument_common(
65    price_precision: u8,
66    size_precision: u8,
67    size_increment: Quantity,
68    multiplier: Quantity,
69    margin_init: Decimal,
70    margin_maint: Decimal,
71    price_increment: Option<Price>,
72    lot_size: Option<Quantity>,
73    max_quantity: Option<Quantity>,
74    min_quantity: Option<Quantity>,
75    max_notional: Option<Money>,
76    min_notional: Option<Money>,
77    max_price: Option<Price>,
78    min_price: Option<Price>,
79) -> anyhow::Result<()> {
80    check_positive_quantity(size_increment, "size_increment")?;
81    check_equal_u8(
82        size_increment.precision,
83        size_precision,
84        "size_increment.precision",
85        "size_precision",
86    )?;
87    check_positive_quantity(multiplier, "multiplier")?;
88    check_positive_decimal(margin_init, "margin_init")?;
89    check_positive_decimal(margin_maint, "margin_maint")?;
90
91    if let Some(price_increment) = price_increment {
92        check_positive_price(price_increment, "price_increment")?;
93        check_equal_u8(
94            price_increment.precision,
95            price_precision,
96            "price_increment.precision",
97            "price_precision",
98        )?;
99    }
100
101    if let Some(lot) = lot_size {
102        check_positive_quantity(lot, "lot_size")?;
103    }
104
105    if let Some(quantity) = max_quantity {
106        check_positive_quantity(quantity, "max_quantity")?;
107    }
108
109    if let Some(quantity) = min_quantity {
110        check_positive_quantity(quantity, "max_quantity")?;
111    }
112
113    if let Some(notional) = max_notional {
114        check_positive_money(notional, "max_notional")?;
115    }
116
117    if let Some(notional) = min_notional {
118        check_positive_money(notional, "min_notional")?;
119    }
120
121    if let Some(max_price) = max_price {
122        check_positive_price(max_price, "max_price")?;
123        check_equal_u8(
124            max_price.precision,
125            price_precision,
126            "max_price.precision",
127            "price_precision",
128        )?;
129    }
130    if let Some(min_price) = min_price {
131        check_positive_price(min_price, "min_price")?;
132        check_equal_u8(
133            min_price.precision,
134            price_precision,
135            "min_price.precision",
136            "price_precision",
137        )?;
138    }
139
140    if let (Some(min), Some(max)) = (min_price, max_price) {
141        check_predicate_true(min.raw <= max.raw, "min_price exceeds max_price")?;
142    }
143
144    Ok(())
145}
146
147pub trait TickSchemeRule: Display {
148    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
149    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
150}
151
152#[derive(Clone, Copy, Debug)]
153pub struct FixedTickScheme {
154    tick: f64,
155}
156
157impl PartialEq for FixedTickScheme {
158    fn eq(&self, other: &Self) -> bool {
159        self.tick == other.tick
160    }
161}
162impl Eq for FixedTickScheme {}
163
164impl FixedTickScheme {
165    #[allow(clippy::missing_errors_doc)]
166    pub fn new(tick: f64) -> anyhow::Result<Self> {
167        check_predicate_true(tick > 0.0, "tick must be positive")?;
168        Ok(Self { tick })
169    }
170}
171
172impl TickSchemeRule for FixedTickScheme {
173    #[inline(always)]
174    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
175        let base = (value / self.tick).floor() * self.tick;
176        Some(Price::new(base - (n as f64) * self.tick, precision))
177    }
178
179    #[inline(always)]
180    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
181        let base = (value / self.tick).ceil() * self.tick;
182        Some(Price::new(base + (n as f64) * self.tick, precision))
183    }
184}
185
186impl Display for FixedTickScheme {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "FIXED")
189    }
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193pub enum TickScheme {
194    Fixed(FixedTickScheme),
195    Crypto,
196}
197
198impl TickSchemeRule for TickScheme {
199    #[inline(always)]
200    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
201        match self {
202            Self::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
203            Self::Crypto => {
204                let increment: f64 = 0.01;
205                let base = (value / increment).floor() * increment;
206                Some(Price::new(base - (n as f64) * increment, precision))
207            }
208        }
209    }
210
211    #[inline(always)]
212    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
213        match self {
214            Self::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
215            Self::Crypto => {
216                let increment: f64 = 0.01;
217                let base = (value / increment).ceil() * increment;
218                Some(Price::new(base + (n as f64) * increment, precision))
219            }
220        }
221    }
222}
223
224impl Display for TickScheme {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            Self::Fixed(_) => write!(f, "FIXED"),
228            Self::Crypto => write!(f, "CRYPTO_0_01"),
229        }
230    }
231}
232
233impl FromStr for TickScheme {
234    type Err = anyhow::Error;
235
236    fn from_str(s: &str) -> Result<Self, Self::Err> {
237        match s.trim().to_ascii_uppercase().as_str() {
238            "FIXED" => Ok(Self::Fixed(FixedTickScheme::new(1.0)?)),
239            "CRYPTO_0_01" => Ok(Self::Crypto),
240            _ => anyhow::bail!("unknown tick scheme {s}"),
241        }
242    }
243}
244
245#[enum_dispatch]
246pub trait Instrument: 'static + Send {
247    fn tick_scheme(&self) -> Option<&dyn TickSchemeRule> {
248        None
249    }
250
251    fn into_any(self) -> InstrumentAny
252    where
253        Self: Sized,
254        InstrumentAny: From<Self>,
255    {
256        self.into()
257    }
258
259    fn id(&self) -> InstrumentId;
260    fn symbol(&self) -> Symbol {
261        self.id().symbol
262    }
263    fn venue(&self) -> Venue {
264        self.id().venue
265    }
266
267    fn raw_symbol(&self) -> Symbol;
268    fn asset_class(&self) -> AssetClass;
269    fn instrument_class(&self) -> InstrumentClass;
270
271    fn underlying(&self) -> Option<Ustr>;
272    fn base_currency(&self) -> Option<Currency>;
273    fn quote_currency(&self) -> Currency;
274    fn settlement_currency(&self) -> Currency;
275
276    /// # Panics
277    ///
278    /// Panics if the instrument is inverse and does not have a base currency.
279    fn cost_currency(&self) -> Currency {
280        if self.is_inverse() {
281            self.base_currency()
282                .expect("inverse instrument without base_currency")
283        } else {
284            self.quote_currency()
285        }
286    }
287
288    fn isin(&self) -> Option<Ustr>;
289    fn option_kind(&self) -> Option<OptionKind>;
290    fn exchange(&self) -> Option<Ustr>;
291    fn strike_price(&self) -> Option<Price>;
292
293    fn activation_ns(&self) -> Option<UnixNanos>;
294    fn expiration_ns(&self) -> Option<UnixNanos>;
295    fn has_expiration(&self) -> bool {
296        self.instrument_class().has_expiration()
297    }
298
299    fn is_inverse(&self) -> bool;
300    fn is_quanto(&self) -> bool {
301        self.base_currency()
302            .is_some_and(|currency| currency != self.settlement_currency())
303    }
304
305    fn price_precision(&self) -> u8;
306    fn size_precision(&self) -> u8;
307    fn price_increment(&self) -> Price;
308    fn size_increment(&self) -> Quantity;
309
310    fn multiplier(&self) -> Quantity;
311    fn lot_size(&self) -> Option<Quantity>;
312    fn max_quantity(&self) -> Option<Quantity>;
313    fn min_quantity(&self) -> Option<Quantity>;
314    fn max_notional(&self) -> Option<Money>;
315    fn min_notional(&self) -> Option<Money>;
316    fn max_price(&self) -> Option<Price>;
317    fn min_price(&self) -> Option<Price>;
318
319    fn margin_init(&self) -> Decimal {
320        dec!(0)
321    }
322    fn margin_maint(&self) -> Decimal {
323        dec!(0)
324    }
325    fn maker_fee(&self) -> Decimal {
326        dec!(0)
327    }
328    fn taker_fee(&self) -> Decimal {
329        dec!(0)
330    }
331
332    fn ts_event(&self) -> UnixNanos;
333    fn ts_init(&self) -> UnixNanos;
334
335    fn _min_price_increment_precision(&self) -> u8 {
336        // TODO: Optimize by storing min price increment precision (without trailing zeros)
337        min_increment_precision_from_str(&self.price_increment().to_string())
338    }
339
340    fn _min_size_increment_precision(&self) -> u8 {
341        // TODO: Optimize by storing min size increment precision (without trailing zeros)
342        min_increment_precision_from_str(&self.size_increment().to_string())
343    }
344
345    /// # Errors
346    ///
347    /// Returns an error if the value is not finite or cannot be converted to a `Price`.
348    #[inline(always)]
349    fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
350        let dec_value = Decimal::from_str(&value.to_string())
351            .map_err(|_| anyhow::anyhow!("non-finite value passed to make_price"))?;
352        let precision = self._min_price_increment_precision() as u32;
353        let rounded_decimal =
354            dec_value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
355        Price::from_decimal_dp(rounded_decimal, self.price_precision())
356    }
357
358    fn make_price(&self, value: f64) -> Price {
359        self.try_make_price(value).unwrap()
360    }
361
362    /// # Errors
363    ///
364    /// Returns an error if the value is not finite or cannot be converted to a `Quantity`.
365    #[inline(always)]
366    fn try_make_qty(&self, value: f64, round_down: Option<bool>) -> anyhow::Result<Quantity> {
367        let dec_value = Decimal::from_str(&value.to_string())
368            .map_err(|_| anyhow::anyhow!("non-finite value passed to make_qty"))?;
369        let precision = self._min_size_increment_precision() as u32;
370        let strategy = if round_down.unwrap_or(false) {
371            RoundingStrategy::ToZero
372        } else {
373            RoundingStrategy::MidpointNearestEven
374        };
375        let rounded = dec_value.round_dp_with_strategy(precision, strategy);
376        if dec_value > Decimal::ZERO && rounded.is_zero() {
377            anyhow::bail!("value rounded to zero for quantity");
378        }
379        Quantity::from_decimal_dp(rounded, self.size_precision())
380    }
381
382    fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
383        self.try_make_qty(value, round_down).unwrap()
384    }
385
386    /// # Errors
387    ///
388    /// Returns an error if the value cannot be converted to a `Quantity`.
389    fn try_calculate_base_quantity(
390        &self,
391        quantity: Quantity,
392        last_price: Price,
393    ) -> anyhow::Result<Quantity> {
394        let precision = self._min_size_increment_precision() as u32;
395        let value = (quantity.as_decimal() / last_price.as_decimal())
396            .round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
397        Quantity::from_decimal_dp(value, self.size_precision())
398    }
399
400    fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
401        self.try_calculate_base_quantity(quantity, last_price)
402            .unwrap()
403    }
404
405    /// # Panics
406    ///
407    /// Panics if the instrument is inverse and does not have a base currency.
408    #[inline(always)]
409    fn calculate_notional_value(
410        &self,
411        quantity: Quantity,
412        price: Price,
413        use_quote_for_inverse: Option<bool>,
414    ) -> Money {
415        let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
416        let (amount, currency) = if self.is_inverse() {
417            if use_quote_inverse {
418                (quantity.as_decimal(), self.quote_currency())
419            } else {
420                let amount =
421                    quantity.as_decimal() * self.multiplier().as_decimal() / price.as_decimal();
422                let currency = self
423                    .base_currency()
424                    .expect("inverse instrument without base_currency");
425                (amount, currency)
426            }
427        } else if self.is_quanto() {
428            let amount =
429                quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
430            (amount, self.settlement_currency())
431        } else {
432            let amount =
433                quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
434            (amount, self.quote_currency())
435        };
436
437        Money::from_decimal(amount, currency).expect("Invalid notional value")
438    }
439
440    #[inline(always)]
441    fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
442        let price = if let Some(scheme) = self.tick_scheme() {
443            scheme.next_bid_price(value, n, self.price_precision())?
444        } else {
445            let value = Decimal::from_str(&value.to_string()).ok()?;
446            let increment = self.price_increment().as_decimal();
447            if increment.is_zero() {
448                return None;
449            }
450            let base = (value / increment).floor() * increment;
451            let result = base - Decimal::from(n) * increment;
452            Price::from_decimal_dp(result, self.price_precision()).ok()?
453        };
454
455        if self.min_price().is_some_and(|min| price < min)
456            || self.max_price().is_some_and(|max| price > max)
457        {
458            return None;
459        }
460
461        Some(price)
462    }
463
464    #[inline(always)]
465    fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
466        let price = if let Some(scheme) = self.tick_scheme() {
467            scheme.next_ask_price(value, n, self.price_precision())?
468        } else {
469            let value = Decimal::from_str(&value.to_string()).ok()?;
470            let increment = self.price_increment().as_decimal();
471            if increment.is_zero() {
472                return None;
473            }
474            let base = (value / increment).ceil() * increment;
475            let result = base + Decimal::from(n) * increment;
476            Price::from_decimal_dp(result, self.price_precision()).ok()?
477        };
478
479        if self.min_price().is_some_and(|min| price < min)
480            || self.max_price().is_some_and(|max| price > max)
481        {
482            return None;
483        }
484
485        Some(price)
486    }
487
488    #[inline]
489    fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
490        let mut prices = Vec::with_capacity(n);
491
492        for i in 0..n {
493            if let Some(price) = self.next_bid_price(value, i as i32) {
494                prices.push(price);
495            } else {
496                break;
497            }
498        }
499
500        prices
501    }
502
503    #[inline]
504    fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
505        let mut prices = Vec::with_capacity(n);
506
507        for i in 0..n {
508            if let Some(price) = self.next_ask_price(value, i as i32) {
509                prices.push(price);
510            } else {
511                break;
512            }
513        }
514
515        prices
516    }
517}
518
519impl Display for CurrencyPair {
520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521        write!(
522            f,
523            "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
524price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
525            stringify!(CurrencyPair),
526            self.id,
527            self.tick_scheme()
528                .map_or_else(|| "None".into(), |s| s.to_string()),
529            self.price_precision(),
530            self.size_precision(),
531            self.price_increment(),
532            self.size_increment(),
533            self.multiplier(),
534            self.margin_init(),
535            self.margin_maint(),
536        )
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use std::str::FromStr;
543
544    use proptest::prelude::*;
545    use rstest::rstest;
546    use rust_decimal::{Decimal, prelude::*};
547
548    use super::*;
549    use crate::{instruments::stubs::*, types::Money};
550
551    pub fn default_price_increment(precision: u8) -> Price {
552        let step = 10f64.powi(-(precision as i32));
553        Price::new(step, precision)
554    }
555
556    #[rstest]
557    fn default_increment_precision() {
558        let inc = default_price_increment(2);
559        assert_eq!(inc, Price::new(0.01, 2));
560    }
561
562    #[rstest]
563    #[case(Price::new(0.5, 1), 1)] // 0.5 -> precision 1
564    #[case(Price::new(0.50, 2), 1)] // 0.50 -> precision 1 (trailing zero ignored)
565    #[case(Price::new(0.500, 3), 1)] // 0.500 -> precision 1
566    #[case(Price::new(0.01, 2), 2)] // 0.01 -> precision 2
567    #[case(Price::new(0.010, 3), 2)] // 0.010 -> precision 2
568    #[case(Price::new(0.25, 2), 2)] // 0.25 -> precision 2
569    #[case(Price::new(1.0, 1), 1)] // 1.0 -> precision 1
570    #[case(Price::new(1.00, 2), 2)] // 1.00 -> precision 2 (all zeros)
571    #[case(Price::new(100.0, 0), 0)] // 100 -> precision 0
572    #[case(Price::new(0.001, 3), 3)] // 0.001 -> precision 3
573    fn test_min_increment_precision(#[case] price: Price, #[case] expected: u8) {
574        assert_eq!(
575            nautilus_core::parsing::min_increment_precision_from_str(&price.to_string()),
576            expected
577        );
578    }
579
580    #[rstest]
581    #[case(1.5, "1.500000")]
582    #[case(2.5, "2.500000")]
583    #[case(1.2345678, "1.234568")]
584    #[case(0.000123, "0.000123")]
585    #[case(99999.999999, "99999.999999")]
586    fn make_qty_rounding(
587        currency_pair_btcusdt: CurrencyPair,
588        #[case] input: f64,
589        #[case] expected: &str,
590    ) {
591        assert_eq!(
592            currency_pair_btcusdt.make_qty(input, None).to_string(),
593            expected
594        );
595    }
596
597    #[rstest]
598    #[case(1.2345678, "1.234567")]
599    #[case(1.9999999, "1.999999")]
600    #[case(0.00012345, "0.000123")]
601    #[case(10.9999999, "10.999999")]
602    fn make_qty_round_down(
603        currency_pair_btcusdt: CurrencyPair,
604        #[case] input: f64,
605        #[case] expected: &str,
606    ) {
607        assert_eq!(
608            currency_pair_btcusdt
609                .make_qty(input, Some(true))
610                .to_string(),
611            expected
612        );
613    }
614
615    #[rstest]
616    #[case(1.2345678, "1.23457")]
617    #[case(2.3456781, "2.34568")]
618    #[case(0.00001, "0.00001")]
619    fn make_qty_precision(
620        currency_pair_ethusdt: CurrencyPair,
621        #[case] input: f64,
622        #[case] expected: &str,
623    ) {
624        assert_eq!(
625            currency_pair_ethusdt.make_qty(input, None).to_string(),
626            expected
627        );
628    }
629
630    #[rstest]
631    #[case(1.2345675, "1.234568")]
632    #[case(1.2345665, "1.234566")]
633    fn make_qty_half_even(
634        currency_pair_btcusdt: CurrencyPair,
635        #[case] input: f64,
636        #[case] expected: &str,
637    ) {
638        assert_eq!(
639            currency_pair_btcusdt.make_qty(input, None).to_string(),
640            expected
641        );
642    }
643
644    #[rstest]
645    #[should_panic]
646    fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
647        currency_pair_btcusdt.make_qty(1e-12, None);
648    }
649
650    #[rstest]
651    fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
652        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
653        let price = currency_pair_btcusdt.make_price(10_000.0);
654        let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
655        let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
656        assert_eq!(notional, expected);
657    }
658
659    #[rstest]
660    fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
661        let start = 10_000.123_4;
662        let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
663        let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
664        assert!(bid_1 < bid_0);
665        let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
666        assert_eq!(asks.len(), 3);
667        assert!(asks[0] > bid_0);
668    }
669
670    #[rstest]
671    #[should_panic]
672    fn validate_negative_margin_init() {
673        let size_increment = Quantity::new(0.01, 2);
674        let multiplier = Quantity::new(1.0, 0);
675
676        validate_instrument_common(
677            2,
678            2,              // size_precision
679            size_increment, // size_increment
680            multiplier,     // multiplier
681            dec!(-0.01),    // margin_init
682            dec!(0.01),     // margin_maint
683            None,           // price_increment
684            None,           // lot_size
685            None,           // max_quantity
686            None,           // min_quantity
687            None,           // max_notional
688            None,           // min_notional
689            None,           // max_price
690            None,           // min_price
691        )
692        .unwrap();
693    }
694
695    #[rstest]
696    #[should_panic]
697    fn validate_negative_margin_maint() {
698        let size_increment = Quantity::new(0.01, 2);
699        let multiplier = Quantity::new(1.0, 0);
700
701        validate_instrument_common(
702            2,
703            2,              // size_precision
704            size_increment, // size_increment
705            multiplier,     // multiplier
706            dec!(0.01),     // margin_init
707            dec!(-0.01),    // margin_maint
708            None,           // price_increment
709            None,           // lot_size
710            None,           // max_quantity
711            None,           // min_quantity
712            None,           // max_notional
713            None,           // min_notional
714            None,           // max_price
715            None,           // min_price
716        )
717        .unwrap();
718    }
719
720    #[rstest]
721    #[should_panic]
722    fn validate_negative_max_qty() {
723        let quantity = Quantity::new(0.0, 0);
724        validate_instrument_common(
725            2,
726            2,
727            Quantity::new(0.01, 2),
728            Quantity::new(1.0, 0),
729            dec!(0),
730            dec!(0),
731            None,
732            None,
733            Some(quantity),
734            None,
735            None,
736            None,
737            None,
738            None,
739        )
740        .unwrap();
741    }
742
743    #[rstest]
744    fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
745        let price = currency_pair_ethusdt.make_price(-123.456_789);
746        assert!(price.as_f64() < 0.0);
747    }
748
749    #[rstest]
750    fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
751        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
752        let price = currency_pair_btcusdt.make_price(10_000.0);
753        let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
754        assert_eq!(base.to_string(), "0.000200");
755    }
756
757    #[rstest]
758    fn fixed_tick_scheme_prices() {
759        let scheme = FixedTickScheme::new(0.5).unwrap();
760        let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
761        let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
762        assert!(bid < ask);
763    }
764
765    #[rstest]
766    #[should_panic]
767    fn fixed_tick_negative() {
768        FixedTickScheme::new(-0.01).unwrap();
769    }
770
771    #[rstest]
772    fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
773        let start = 10_000.0;
774        let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
775        assert_eq!(bids.len(), 5);
776        for i in 1..bids.len() {
777            assert!(bids[i] < bids[i - 1]);
778        }
779    }
780
781    #[rstest]
782    fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
783        let start = 10_000.0;
784        let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
785        assert_eq!(asks.len(), 5);
786        for i in 1..asks.len() {
787            assert!(asks[i] > asks[i - 1]);
788        }
789    }
790
791    #[rstest]
792    fn fixed_tick_boundary() {
793        let scheme = FixedTickScheme::new(0.5).unwrap();
794        let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
795        assert_eq!(price, Price::new(10.5, 2));
796    }
797
798    #[rstest]
799    #[should_panic]
800    fn validate_price_increment_precision_mismatch() {
801        let size_increment = Quantity::new(0.01, 2);
802        let multiplier = Quantity::new(1.0, 0);
803        let price_increment = Price::new(0.001, 3);
804        validate_instrument_common(
805            2,
806            2,
807            size_increment,
808            multiplier,
809            dec!(0),
810            dec!(0),
811            Some(price_increment),
812            None,
813            None,
814            None,
815            None,
816            None,
817            None,
818            None,
819        )
820        .unwrap();
821    }
822
823    #[rstest]
824    #[should_panic]
825    fn validate_min_price_exceeds_max_price() {
826        let size_increment = Quantity::new(0.01, 2);
827        let multiplier = Quantity::new(1.0, 0);
828        let min_price = Price::new(10.0, 2);
829        let max_price = Price::new(5.0, 2);
830        validate_instrument_common(
831            2,
832            2,
833            size_increment,
834            multiplier,
835            dec!(0),
836            dec!(0),
837            None,
838            None,
839            None,
840            None,
841            None,
842            None,
843            Some(max_price),
844            Some(min_price),
845        )
846        .unwrap();
847    }
848
849    #[rstest]
850    fn validate_instrument_common_ok() {
851        let res = validate_instrument_common(
852            2,
853            4,
854            Quantity::new(0.0001, 4),
855            Quantity::new(1.0, 0),
856            dec!(0.02),
857            dec!(0.01),
858            Some(Price::new(0.01, 2)),
859            None,
860            None,
861            None,
862            None,
863            None,
864            None,
865            None,
866        );
867        assert!(matches!(res, Ok(())));
868    }
869
870    #[rstest]
871    #[should_panic]
872    fn validate_multiple_errors() {
873        validate_instrument_common(
874            2,
875            2,
876            Quantity::new(-0.01, 2),
877            Quantity::new(0.0, 0),
878            dec!(0),
879            dec!(0),
880            None,
881            None,
882            None,
883            None,
884            None,
885            None,
886            None,
887            None,
888        )
889        .unwrap();
890    }
891
892    #[rstest]
893    #[case(1.234_999_9, false, "1.235000")]
894    #[case(1.234_999_9, true, "1.234999")]
895    fn make_qty_boundary(
896        currency_pair_btcusdt: CurrencyPair,
897        #[case] input: f64,
898        #[case] round_down: bool,
899        #[case] expected: &str,
900    ) {
901        let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
902        assert_eq!(quantity.to_string(), expected);
903    }
904
905    #[rstest]
906    fn fixed_tick_multiple_steps() {
907        let scheme = FixedTickScheme::new(1.0).unwrap();
908        let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
909        let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
910        assert_eq!(bid, Price::new(8.0, 1));
911        assert_eq!(ask, Price::new(13.0, 1));
912    }
913
914    #[rstest]
915    #[case(1.234_999, 1.23)]
916    #[case(1.235, 1.24)]
917    #[case(1.235_001, 1.24)]
918    fn make_price_rounding_parity(
919        currency_pair_btcusdt: CurrencyPair,
920        #[case] input: f64,
921        #[case] expected: f64,
922    ) {
923        let price = currency_pair_btcusdt.make_price(input);
924        assert!((price.as_f64() - expected).abs() < 1e-9);
925    }
926
927    #[rstest]
928    fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
929        let rounding_precision = std::cmp::min(
930            currency_pair_btcusdt.price_precision(),
931            currency_pair_btcusdt._min_price_increment_precision(),
932        );
933        let step = 10f64.powi(-(rounding_precision as i32));
934        let base_even_multiple = 42.0;
935        let base_value = step * base_even_multiple;
936        let delta = step / 2000.0;
937        let value_below = base_value + 0.5 * step - delta;
938        let value_exact = base_value + 0.5 * step;
939        let value_above = base_value + 0.5 * step + delta;
940        let price_below = currency_pair_btcusdt.make_price(value_below);
941        let price_exact = currency_pair_btcusdt.make_price(value_exact);
942        let price_above = currency_pair_btcusdt.make_price(value_above);
943        assert_eq!(price_below, price_exact);
944        assert_ne!(price_exact, price_above);
945    }
946
947    #[rstest]
948    fn tick_scheme_round_trip() {
949        let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
950        assert_eq!(scheme.to_string(), "CRYPTO_0_01");
951    }
952
953    #[rstest]
954    fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
955        assert!(ethbtc_quanto.is_quanto());
956    }
957
958    #[rstest]
959    fn notional_quanto(ethbtc_quanto: CryptoFuture) {
960        let quantity = ethbtc_quanto.make_qty(5.0, None);
961        let price = ethbtc_quanto.make_price(0.036);
962        let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
963        let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
964        assert_eq!(notional, expected);
965    }
966
967    #[rstest]
968    fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
969        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
970        let price = xbtusd_inverse_perp.make_price(50_000.0);
971        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
972        let expected = Money::new(
973            100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
974            xbtusd_inverse_perp.base_currency().unwrap(),
975        );
976        assert_eq!(notional, expected);
977    }
978
979    #[rstest]
980    fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
981        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
982        let price = xbtusd_inverse_perp.make_price(50_000.0);
983        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
984        let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
985        assert_eq!(notional, expected);
986    }
987
988    #[rstest]
989    #[should_panic]
990    fn validate_non_positive_max_price() {
991        let size_increment = Quantity::new(0.01, 2);
992        let multiplier = Quantity::new(1.0, 0);
993        let max_price = Price::new(0.0, 2);
994        validate_instrument_common(
995            2,
996            2,
997            size_increment,
998            multiplier,
999            dec!(0),
1000            dec!(0),
1001            None,
1002            None,
1003            None,
1004            None,
1005            None,
1006            None,
1007            Some(max_price),
1008            None,
1009        )
1010        .unwrap();
1011    }
1012
1013    #[rstest]
1014    #[should_panic]
1015    fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1016        let size_increment = Quantity::new(0.01, 2);
1017        let multiplier = Quantity::new(1.0, 0);
1018        let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1019        validate_instrument_common(
1020            2,
1021            2,
1022            size_increment,
1023            multiplier,
1024            dec!(0),
1025            dec!(0),
1026            None,
1027            None,
1028            None,
1029            None,
1030            Some(max_notional),
1031            None,
1032            None,
1033            None,
1034        )
1035        .unwrap();
1036    }
1037
1038    #[rstest]
1039    #[should_panic]
1040    fn validate_price_increment_min_price_precision_mismatch() {
1041        let size_increment = Quantity::new(0.01, 2);
1042        let multiplier = Quantity::new(1.0, 0);
1043        let price_increment = Price::new(0.01, 2);
1044        let min_price = Price::new(1.0, 3);
1045        validate_instrument_common(
1046            2,
1047            2,
1048            size_increment,
1049            multiplier,
1050            dec!(0),
1051            dec!(0),
1052            Some(price_increment),
1053            None,
1054            None,
1055            None,
1056            None,
1057            None,
1058            None,
1059            Some(min_price),
1060        )
1061        .unwrap();
1062    }
1063
1064    #[rstest]
1065    #[should_panic]
1066    fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1067        let size_increment = Quantity::new(0.01, 2);
1068        let multiplier = Quantity::new(1.0, 0);
1069        let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1070        let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1071        validate_instrument_common(
1072            2,
1073            2,
1074            size_increment,
1075            multiplier,
1076            dec!(0),
1077            dec!(0),
1078            None,
1079            None,
1080            None,
1081            None,
1082            Some(max_notional),
1083            Some(min_notional),
1084            None,
1085            None,
1086        )
1087        .unwrap();
1088    }
1089
1090    #[rstest]
1091    #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1092    #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1093    #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1094    #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1095    #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1096    #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1097    #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1098    #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1099    #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1100    fn base_qty_rounding(
1101        currency_pair_btcusdt: CurrencyPair,
1102        #[case] q: Decimal,
1103        #[case] px: Decimal,
1104        #[case] expected: f64,
1105    ) {
1106        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1107        let price = Price::new(px.to_f64().unwrap(), 8);
1108        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1109        assert!((base.as_f64() - expected).abs() < 1e-9);
1110    }
1111
1112    proptest! {
1113        #[rstest]
1114        fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1115            let instrument = currency_pair_btcusdt();
1116            let price = instrument.make_price(input);
1117            prop_assert!(price.as_f64().is_finite());
1118            let quantity = instrument.make_qty(input, None);
1119            prop_assert!(quantity.as_f64().is_finite());
1120        }
1121    }
1122
1123    #[rstest]
1124    fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1125        if let Some(max_price) = currency_pair_btcusdt.max_price() {
1126            assert!(
1127                currency_pair_btcusdt
1128                    .next_ask_price(max_price.as_f64(), 1)
1129                    .is_none()
1130            );
1131        }
1132    }
1133
1134    #[rstest]
1135    fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1136        if let Some(max_price) = currency_pair_ethusdt.max_price() {
1137            assert!(
1138                currency_pair_ethusdt
1139                    .next_ask_price(max_price.as_f64(), 1)
1140                    .is_none()
1141            );
1142        }
1143    }
1144
1145    #[rstest]
1146    fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1147        if let Some(min_price) = currency_pair_btcusdt.min_price() {
1148            assert!(
1149                currency_pair_btcusdt
1150                    .next_bid_price(min_price.as_f64(), 1)
1151                    .is_none()
1152            );
1153        }
1154    }
1155
1156    #[rstest]
1157    fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1158        if let Some(min_price) = currency_pair_ethusdt.min_price() {
1159            assert!(
1160                currency_pair_ethusdt
1161                    .next_bid_price(min_price.as_f64(), 1)
1162                    .is_none()
1163            );
1164        }
1165    }
1166
1167    #[rstest]
1168    fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1169        if let Some(max_price) = ethbtc_quanto.max_price() {
1170            assert!(
1171                ethbtc_quanto
1172                    .next_ask_price(max_price.as_f64(), 1)
1173                    .is_none()
1174            );
1175        }
1176    }
1177
1178    #[rstest]
1179    #[case(0.999_999, false)]
1180    #[case(0.999_999, true)]
1181    #[case(1.000_000_1, false)]
1182    #[case(1.000_000_1, true)]
1183    #[case(1.234_5, false)]
1184    #[case(1.234_5, true)]
1185    #[case(2.345_5, false)]
1186    #[case(2.345_5, true)]
1187    #[case(0.000_999_999, false)]
1188    #[case(0.000_999_999, true)]
1189    fn quantity_rounding_grid(
1190        currency_pair_btcusdt: CurrencyPair,
1191        #[case] input: f64,
1192        #[case] round_down: bool,
1193    ) {
1194        let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1195        assert!(qty.as_f64().is_finite());
1196    }
1197
1198    #[rstest]
1199    fn pyo3_failure_tick_scheme_unknown() {
1200        assert!(TickScheme::from_str("UNKNOWN").is_err());
1201    }
1202
1203    #[rstest]
1204    fn pyo3_failure_fixed_tick_zero() {
1205        assert!(FixedTickScheme::new(0.0).is_err());
1206    }
1207
1208    #[rstest]
1209    fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1210        let size_increment = Quantity::new(0.01, 2);
1211        let multiplier = Quantity::new(1.0, 0);
1212        let price_increment = Price::new(0.01, 2);
1213        let max_price = Price::new(1.0, 3);
1214        let res = validate_instrument_common(
1215            2,
1216            2,
1217            size_increment,
1218            multiplier,
1219            dec!(0),
1220            dec!(0),
1221            Some(price_increment),
1222            None,
1223            None,
1224            None,
1225            None,
1226            None,
1227            Some(max_price),
1228            None,
1229        );
1230        assert!(res.is_err());
1231    }
1232
1233    #[rstest]
1234    #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1235    #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1236    #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1237    #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1238    #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1239    #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1240    #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1241    #[case::dp16(
1242        Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1243        Decimal::new(2, 0),
1244        500.0
1245    )]
1246    #[case::dp17(
1247        Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1248        Decimal::new(2, 0),
1249        500.0
1250    )]
1251    fn base_qty_rounding_high_dp(
1252        currency_pair_btcusdt: CurrencyPair,
1253        #[case] q: Decimal,
1254        #[case] px: Decimal,
1255        #[case] expected: f64,
1256    ) {
1257        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1258        let price = Price::new(px.to_f64().unwrap(), 8);
1259        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1260        assert!((base.as_f64() - expected).abs() < 1e-9);
1261    }
1262
1263    #[rstest]
1264    fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1265        let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1266        assert!(check_positive_money(money, "money").is_ok());
1267    }
1268
1269    #[rstest]
1270    #[should_panic]
1271    fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1272        let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1273        check_positive_money(money, "money").unwrap();
1274    }
1275
1276    #[rstest]
1277    #[should_panic]
1278    fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1279        let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1280        check_positive_money(money, "money").unwrap();
1281    }
1282
1283    #[rstest]
1284    fn make_price_with_trailing_zeros_in_increment() {
1285        // Test instrument with price_increment 0.50 (precision 2, but min_increment_precision 1)
1286        // This verifies that trailing zeros in price_increment are handled correctly
1287        let instrument = CurrencyPair::new(
1288            InstrumentId::from("TEST.VENUE"),
1289            Symbol::from("TEST"),
1290            Currency::from("BTC"),
1291            Currency::from("USD"),
1292            2,                   // price_precision
1293            2,                   // size_precision
1294            Price::new(0.50, 2), // price_increment with trailing zero
1295            Quantity::from("0.01"),
1296            None,
1297            None,
1298            None,
1299            None,
1300            None,
1301            None,
1302            None,
1303            None,
1304            None,
1305            None,
1306            None,
1307            None,
1308            UnixNanos::default(),
1309            UnixNanos::default(),
1310        );
1311
1312        // Verify min_increment_precision is 1 (ignoring trailing zero)
1313        assert_eq!(instrument._min_price_increment_precision(), 1);
1314
1315        // Test that make_price rounds to min_increment_precision (1)
1316        // 1.234 should round to 1.2 (not 1.23)
1317        let price = instrument.make_price(1.234);
1318        assert_eq!(price.as_f64(), 1.2);
1319
1320        // 1.25 should round to 1.2 (half-even rounding)
1321        let price = instrument.make_price(1.25);
1322        assert_eq!(price.as_f64(), 1.2);
1323
1324        // 1.35 should round to 1.4 (half-even rounding)
1325        let price = instrument.make_price(1.35);
1326        assert_eq!(price.as_f64(), 1.4);
1327
1328        // But output precision should still be 2
1329        assert_eq!(price.precision, 2);
1330    }
1331
1332    #[rstest]
1333    fn make_qty_with_trailing_zeros_in_increment() {
1334        // Test instrument with size_increment 0.50 (precision 2, but min_increment_precision 1)
1335        let instrument = CurrencyPair::new(
1336            InstrumentId::from("TEST.VENUE"),
1337            Symbol::from("TEST"),
1338            Currency::from("BTC"),
1339            Currency::from("USD"),
1340            2, // price_precision
1341            2, // size_precision
1342            Price::new(0.01, 2),
1343            Quantity::new(0.50, 2), // size_increment with trailing zero
1344            None,
1345            None,
1346            None,
1347            None,
1348            None,
1349            None,
1350            None,
1351            None,
1352            None,
1353            None,
1354            None,
1355            None,
1356            UnixNanos::default(),
1357            UnixNanos::default(),
1358        );
1359
1360        // Verify min_increment_precision is 1 (ignoring trailing zero)
1361        assert_eq!(instrument._min_size_increment_precision(), 1);
1362
1363        // Test that make_qty rounds to min_increment_precision (1)
1364        // 1.234 should round to 1.2 (not 1.23)
1365        let qty = instrument.make_qty(1.234, None);
1366        assert_eq!(qty.as_f64(), 1.2);
1367
1368        // 1.25 should round to 1.2 (half-even rounding)
1369        let qty = instrument.make_qty(1.25, None);
1370        assert_eq!(qty.as_f64(), 1.2);
1371
1372        // 1.35 should round to 1.4 (half-even rounding)
1373        let qty = instrument.make_qty(1.35, None);
1374        assert_eq!(qty.as_f64(), 1.4);
1375
1376        // But output precision should still be 2
1377        assert_eq!(qty.precision, 2);
1378
1379        // Test round_down option
1380        let qty = instrument.make_qty(1.99, Some(true));
1381        assert_eq!(qty.as_f64(), 1.9);
1382    }
1383
1384    #[rstest]
1385    #[case(InstrumentClass::Future, true)]
1386    #[case(InstrumentClass::FuturesSpread, true)]
1387    #[case(InstrumentClass::Option, true)]
1388    #[case(InstrumentClass::OptionSpread, true)]
1389    #[case(InstrumentClass::Spot, false)]
1390    #[case(InstrumentClass::Swap, false)]
1391    #[case(InstrumentClass::Forward, false)]
1392    #[case(InstrumentClass::Cfd, false)]
1393    #[case(InstrumentClass::Bond, false)]
1394    #[case(InstrumentClass::Warrant, false)]
1395    #[case(InstrumentClass::SportsBetting, false)]
1396    #[case(InstrumentClass::BinaryOption, false)]
1397    fn test_instrument_class_has_expiration(
1398        #[case] instrument_class: InstrumentClass,
1399        #[case] expected: bool,
1400    ) {
1401        assert_eq!(instrument_class.has_expiration(), expected);
1402    }
1403}