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 = "test-support"))]
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::builder()
960            .instrument_id(InstrumentId::from("TEST.VENUE"))
961            .raw_symbol(Symbol::from("TEST"))
962            .base_currency(Currency::from("BTC"))
963            .quote_currency(Currency::from("USD"))
964            .price_precision(2)
965            .size_precision(2)
966            .price_increment(Price::from("0.50"))
967            .size_increment(Quantity::from("0.01"))
968            .ts_event(UnixNanos::default())
969            .ts_init(UnixNanos::default())
970            .build()
971            .unwrap();
972
973        assert_eq!(
974            instrument.try_normalize_price(Price::from("1.500")),
975            Ok(Price::from("1.50"))
976        );
977        let error = instrument
978            .try_normalize_price(Price::from("1.20"))
979            .unwrap_err();
980
981        assert!(matches!(
982            error,
983            CorrectnessError::PredicateViolation { ref message }
984                if message.contains("not aligned to price increment")
985        ));
986    }
987
988    #[rstest]
989    #[case(Quantity::from("1"), "1.000000")]
990    #[case(Quantity::from("1.0000000"), "1.000000")]
991    fn try_normalize_qty_rewrites_grid_aligned_values(
992        currency_pair_btcusdt: CurrencyPair,
993        #[case] input: Quantity,
994        #[case] expected: &str,
995    ) {
996        let normalized = currency_pair_btcusdt.try_normalize_qty(input).unwrap();
997
998        assert_eq!(normalized.raw, input.raw);
999        assert_eq!(normalized.precision, currency_pair_btcusdt.size_precision());
1000        assert_eq!(normalized, Quantity::from(expected));
1001    }
1002
1003    #[rstest]
1004    fn try_normalize_qty_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
1005        let error = currency_pair_btcusdt
1006            .try_normalize_qty(Quantity::from("1.0000001"))
1007            .unwrap_err();
1008
1009        assert!(matches!(
1010            error,
1011            CorrectnessError::PredicateViolation { ref message }
1012                if message.contains("requires rounding to instrument size precision")
1013        ));
1014    }
1015
1016    #[rstest]
1017    fn try_normalize_qty_rejects_undefined_value(currency_pair_btcusdt: CurrencyPair) {
1018        let error = currency_pair_btcusdt
1019            .try_normalize_qty(Quantity::from_raw(QUANTITY_UNDEF, 0))
1020            .unwrap_err();
1021
1022        match error {
1023            CorrectnessError::InvalidValue {
1024                param,
1025                value,
1026                type_name,
1027            } => {
1028                assert_eq!(param, "quantity");
1029                assert_eq!(value, "QUANTITY_UNDEF");
1030                assert_eq!(type_name, "`Quantity`");
1031            }
1032            _ => panic!("expected invalid quantity error, was {error}"),
1033        }
1034    }
1035
1036    #[cfg(feature = "defi")]
1037    #[rstest]
1038    fn try_normalize_values_reject_mixed_raw_scales() {
1039        let defi_precision = 18;
1040        let price_increment = Price::from_raw(PriceRaw::from(5) * PriceRaw::pow(10, 17), 18);
1041        let size_increment =
1042            Quantity::from_raw(QuantityRaw::from(5_u8) * QuantityRaw::pow(10, 17), 18);
1043        let instrument = CurrencyPair::builder()
1044            .instrument_id(InstrumentId::from("TEST.VENUE"))
1045            .raw_symbol(Symbol::from("TEST"))
1046            .base_currency(Currency::from("BTC"))
1047            .quote_currency(Currency::from("USD"))
1048            .price_precision(defi_precision)
1049            .size_precision(defi_precision)
1050            .price_increment(price_increment)
1051            .size_increment(size_increment)
1052            .ts_event(UnixNanos::default())
1053            .ts_init(UnixNanos::default())
1054            .build()
1055            .unwrap();
1056        let fixed_scale = u32::from(FIXED_PRECISION);
1057        let fixed_price = Price::from_raw(
1058            PriceRaw::pow(10, fixed_scale) * PriceRaw::from(100),
1059            FIXED_PRECISION,
1060        );
1061        let fixed_qty = Quantity::from_raw(
1062            QuantityRaw::pow(10, fixed_scale) * QuantityRaw::from(100_u8),
1063            FIXED_PRECISION,
1064        );
1065
1066        let price_error = instrument.try_normalize_price(fixed_price).unwrap_err();
1067        let qty_error = instrument.try_normalize_qty(fixed_qty).unwrap_err();
1068
1069        assert!(matches!(
1070            price_error,
1071            CorrectnessError::PredicateViolation { ref message }
1072                if message.contains("raw scale does not match instrument price precision")
1073        ));
1074        assert!(matches!(
1075            qty_error,
1076            CorrectnessError::PredicateViolation { ref message }
1077                if message.contains("raw scale does not match instrument size precision")
1078        ));
1079    }
1080
1081    #[rstest]
1082    fn try_normalize_qty_rejects_sub_increment_value() {
1083        let instrument = CurrencyPair::builder()
1084            .instrument_id(InstrumentId::from("TEST.VENUE"))
1085            .raw_symbol(Symbol::from("TEST"))
1086            .base_currency(Currency::from("BTC"))
1087            .quote_currency(Currency::from("USD"))
1088            .price_precision(2)
1089            .size_precision(2)
1090            .price_increment(Price::from("0.01"))
1091            .size_increment(Quantity::from("0.50"))
1092            .ts_event(UnixNanos::default())
1093            .ts_init(UnixNanos::default())
1094            .build()
1095            .unwrap();
1096
1097        assert_eq!(
1098            instrument.try_normalize_qty(Quantity::from("1.500")),
1099            Ok(Quantity::from("1.50"))
1100        );
1101        let error = instrument
1102            .try_normalize_qty(Quantity::from("1.20"))
1103            .unwrap_err();
1104
1105        assert!(matches!(
1106            error,
1107            CorrectnessError::PredicateViolation { ref message }
1108                if message.contains("not aligned to size increment")
1109        ));
1110    }
1111
1112    #[rstest]
1113    #[should_panic(expected = "value rounded to zero")]
1114    fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
1115        currency_pair_btcusdt.make_qty(1e-12, None);
1116    }
1117
1118    #[rstest]
1119    fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
1120        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1121        let price = currency_pair_btcusdt.make_price(10_000.0);
1122        let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
1123        let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
1124        assert_eq!(notional, expected);
1125    }
1126
1127    #[rstest]
1128    fn currency_pair_is_not_quanto(currency_pair_btcusdt: CurrencyPair) {
1129        assert!(!currency_pair_btcusdt.is_quanto());
1130        assert_eq!(currency_pair_btcusdt.cost_currency(), Currency::USDT());
1131    }
1132
1133    #[rstest]
1134    fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
1135        let start = 10_000.123_4;
1136        let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
1137        let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
1138        assert!(bid_1 < bid_0);
1139        let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
1140        assert_eq!(asks.len(), 3);
1141        assert!(asks[0] > bid_0);
1142    }
1143
1144    #[rstest]
1145    fn tick_navigation_uses_tick_scheme() {
1146        let instrument = CurrencyPair::builder()
1147            .instrument_id(InstrumentId::from("TEST.VENUE"))
1148            .raw_symbol(Symbol::from("TEST"))
1149            .base_currency(Currency::from("BTC"))
1150            .quote_currency(Currency::from("USD"))
1151            .price_precision(2)
1152            .size_precision(2)
1153            .price_increment(Price::new(0.01, 2))
1154            .size_increment(Quantity::from("0.01"))
1155            .tick_scheme(Ustr::from("FIXED_PRECISION_1"))
1156            .ts_event(UnixNanos::default())
1157            .ts_init(UnixNanos::default())
1158            .build()
1159            .unwrap();
1160
1161        assert_eq!(
1162            instrument.tick_scheme(),
1163            Some(Ustr::from("FIXED_PRECISION_1"))
1164        );
1165        assert_eq!(instrument.next_bid_price(1.23, 0), Some(Price::new(1.2, 2)));
1166        assert_eq!(instrument.next_ask_price(1.23, 0), Some(Price::new(1.3, 2)));
1167    }
1168
1169    #[rstest]
1170    #[case("BOGUS")]
1171    #[case("FIXED_PRECISION_99")]
1172    fn invalid_tick_scheme_returns_error(#[case] tick_scheme: &str) {
1173        let err = CurrencyPair::builder()
1174            .instrument_id(InstrumentId::from("TEST.VENUE"))
1175            .raw_symbol(Symbol::from("TEST"))
1176            .base_currency(Currency::from("BTC"))
1177            .quote_currency(Currency::from("USD"))
1178            .price_precision(2)
1179            .size_precision(2)
1180            .price_increment(Price::new(0.01, 2))
1181            .size_increment(Quantity::from("0.01"))
1182            .tick_scheme(Ustr::from(tick_scheme))
1183            .ts_event(UnixNanos::default())
1184            .ts_init(UnixNanos::default())
1185            .build()
1186            .expect_err("invalid tick scheme must fail");
1187
1188        assert!(
1189            err.to_string()
1190                .contains("tick_scheme not found in tick schemes"),
1191            "{err}"
1192        );
1193    }
1194
1195    #[rstest]
1196    #[should_panic(expected = "'margin_init' not positive")]
1197    fn validate_negative_margin_init() {
1198        let size_increment = Quantity::new(0.01, 2);
1199        let multiplier = Quantity::new(1.0, 0);
1200
1201        validate_instrument_common(
1202            2,
1203            2,              // size_precision
1204            size_increment, // size_increment
1205            multiplier,     // multiplier
1206            dec!(-0.01),    // margin_init
1207            dec!(0.01),     // margin_maint
1208            None,           // price_increment
1209            None,           // lot_size
1210            None,           // max_quantity
1211            None,           // min_quantity
1212            None,           // max_notional
1213            None,           // min_notional
1214            None,           // max_price
1215            None,           // min_price
1216        )
1217        .expect_display(FAILED);
1218    }
1219
1220    #[rstest]
1221    #[should_panic(expected = "'margin_maint' not positive")]
1222    fn validate_negative_margin_maint() {
1223        let size_increment = Quantity::new(0.01, 2);
1224        let multiplier = Quantity::new(1.0, 0);
1225
1226        validate_instrument_common(
1227            2,
1228            2,              // size_precision
1229            size_increment, // size_increment
1230            multiplier,     // multiplier
1231            dec!(0.01),     // margin_init
1232            dec!(-0.01),    // margin_maint
1233            None,           // price_increment
1234            None,           // lot_size
1235            None,           // max_quantity
1236            None,           // min_quantity
1237            None,           // max_notional
1238            None,           // min_notional
1239            None,           // max_price
1240            None,           // min_price
1241        )
1242        .expect_display(FAILED);
1243    }
1244
1245    #[rstest]
1246    fn validate_negative_max_qty() {
1247        let quantity = Quantity::new(0.0, 0);
1248        let error = validate_instrument_common(
1249            2,
1250            2,
1251            Quantity::new(0.01, 2),
1252            Quantity::new(1.0, 0),
1253            dec!(0.01),
1254            dec!(0.01),
1255            None,
1256            None,
1257            Some(quantity),
1258            None,
1259            None,
1260            None,
1261            None,
1262            None,
1263        )
1264        .unwrap_err();
1265
1266        assert_eq!(
1267            error,
1268            CorrectnessError::NotPositive {
1269                param: "max_quantity".to_string(),
1270                value: "0".to_string(),
1271                type_name: "`Quantity`",
1272            }
1273        );
1274    }
1275
1276    #[rstest]
1277    fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
1278        let price = currency_pair_ethusdt.make_price(-123.456_789);
1279        assert!(price.as_f64() < 0.0);
1280    }
1281
1282    #[rstest]
1283    fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
1284        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1285        let price = currency_pair_btcusdt.make_price(10_000.0);
1286        let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
1287        assert_eq!(base.to_string(), "0.000200");
1288    }
1289
1290    #[rstest]
1291    fn base_quantity_zero_last_price_returns_error(currency_pair_btcusdt: CurrencyPair) {
1292        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1293        let error = currency_pair_btcusdt
1294            .try_calculate_base_quantity(quantity, Price::new(0.0, 2))
1295            .unwrap_err();
1296        assert!(
1297            error.to_string().contains("`last_price` was zero"),
1298            "{error}"
1299        );
1300    }
1301
1302    #[rstest]
1303    #[case(f64::NAN)]
1304    #[case(f64::INFINITY)]
1305    #[case(1e30)] // Finite but not representable as a Decimal
1306    fn make_price_invalid_value_returns_error(
1307        currency_pair_btcusdt: CurrencyPair,
1308        #[case] value: f64,
1309    ) {
1310        let error = currency_pair_btcusdt.try_make_price(value).unwrap_err();
1311        assert!(
1312            error.to_string().contains("invalid `value` for make_price"),
1313            "{error}"
1314        );
1315    }
1316
1317    #[rstest]
1318    fn make_qty_invalid_value_returns_error(currency_pair_btcusdt: CurrencyPair) {
1319        let error = currency_pair_btcusdt
1320            .try_make_qty(f64::NAN, None)
1321            .unwrap_err();
1322        assert!(
1323            error.to_string().contains("invalid `value` for make_qty"),
1324            "{error}"
1325        );
1326    }
1327
1328    #[rstest]
1329    fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1330        let start = 10_000.0;
1331        let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
1332        assert_eq!(bids.len(), 5);
1333        for i in 1..bids.len() {
1334            assert!(bids[i] < bids[i - 1]);
1335        }
1336    }
1337
1338    #[rstest]
1339    fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1340        let start = 10_000.0;
1341        let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
1342        assert_eq!(asks.len(), 5);
1343        for i in 1..asks.len() {
1344            assert!(asks[i] > asks[i - 1]);
1345        }
1346    }
1347
1348    #[rstest]
1349    #[case::bid(true)]
1350    #[case::ask(false)]
1351    fn tick_navigation_rejects_negative_offset(
1352        currency_pair_btcusdt: CurrencyPair,
1353        #[case] bid: bool,
1354    ) {
1355        let price = if bid {
1356            currency_pair_btcusdt.next_bid_price(10_000.0, -1)
1357        } else {
1358            currency_pair_btcusdt.next_ask_price(10_000.0, -1)
1359        };
1360
1361        assert_eq!(price, None);
1362    }
1363
1364    #[rstest]
1365    fn validate_price_increment_precision_mismatch() {
1366        let size_increment = Quantity::new(0.01, 2);
1367        let multiplier = Quantity::new(1.0, 0);
1368        let price_increment = Price::new(0.001, 3);
1369        let error = validate_instrument_common(
1370            2,
1371            2,
1372            size_increment,
1373            multiplier,
1374            dec!(0.01),
1375            dec!(0.01),
1376            Some(price_increment),
1377            None,
1378            None,
1379            None,
1380            None,
1381            None,
1382            None,
1383            None,
1384        )
1385        .unwrap_err();
1386
1387        assert_eq!(
1388            error,
1389            CorrectnessError::EqualityMismatch {
1390                lhs_param: "price_increment.precision".to_string(),
1391                rhs_param: "price_precision".to_string(),
1392                lhs: "3".to_string(),
1393                rhs: "2".to_string(),
1394                type_name: "u8",
1395            }
1396        );
1397    }
1398
1399    #[rstest]
1400    fn validate_min_price_exceeds_max_price() {
1401        let size_increment = Quantity::new(0.01, 2);
1402        let multiplier = Quantity::new(1.0, 0);
1403        let min_price = Price::new(10.0, 2);
1404        let max_price = Price::new(5.0, 2);
1405        let error = validate_instrument_common(
1406            2,
1407            2,
1408            size_increment,
1409            multiplier,
1410            dec!(0.01),
1411            dec!(0.01),
1412            None,
1413            None,
1414            None,
1415            None,
1416            None,
1417            None,
1418            Some(max_price),
1419            Some(min_price),
1420        )
1421        .unwrap_err();
1422
1423        assert_eq!(
1424            error,
1425            CorrectnessError::PredicateViolation {
1426                message: "min_price exceeds max_price".to_string(),
1427            }
1428        );
1429    }
1430
1431    #[rstest]
1432    fn validate_instrument_common_ok() {
1433        let res = validate_instrument_common(
1434            2,
1435            4,
1436            Quantity::new(0.0001, 4),
1437            Quantity::new(1.0, 0),
1438            dec!(0.02),
1439            dec!(0.01),
1440            Some(Price::new(0.01, 2)),
1441            None,
1442            None,
1443            None,
1444            None,
1445            None,
1446            None,
1447            None,
1448        );
1449        assert!(matches!(res, Ok(())));
1450    }
1451
1452    #[rstest]
1453    #[should_panic(expected = "not in range")]
1454    fn validate_multiple_errors() {
1455        validate_instrument_common(
1456            2,
1457            2,
1458            Quantity::new(-0.01, 2),
1459            Quantity::new(0.0, 0),
1460            dec!(0),
1461            dec!(0),
1462            None,
1463            None,
1464            None,
1465            None,
1466            None,
1467            None,
1468            None,
1469            None,
1470        )
1471        .expect_display(FAILED);
1472    }
1473
1474    #[rstest]
1475    #[case(1.234_999_9, false, "1.235000")]
1476    #[case(1.234_999_9, true, "1.234999")]
1477    fn make_qty_boundary(
1478        currency_pair_btcusdt: CurrencyPair,
1479        #[case] input: f64,
1480        #[case] round_down: bool,
1481        #[case] expected: &str,
1482    ) {
1483        let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
1484        assert_eq!(quantity.to_string(), expected);
1485    }
1486
1487    #[rstest]
1488    #[case(1.234_999, 1.23)]
1489    #[case(1.235, 1.24)]
1490    #[case(1.235_001, 1.24)]
1491    fn make_price_rounding_parity(
1492        currency_pair_btcusdt: CurrencyPair,
1493        #[case] input: f64,
1494        #[case] expected: f64,
1495    ) {
1496        let price = currency_pair_btcusdt.make_price(input);
1497        assert!((price.as_f64() - expected).abs() < 1e-9);
1498    }
1499
1500    #[rstest]
1501    fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
1502        let rounding_precision = std::cmp::min(
1503            currency_pair_btcusdt.price_precision(),
1504            currency_pair_btcusdt.min_price_increment_precision(),
1505        );
1506        let step = 10f64.powi(-i32::from(rounding_precision));
1507        let base_even_multiple = 42.0;
1508        let base_value = step * base_even_multiple;
1509        let delta = step / 2000.0;
1510        let value_below = base_value + 0.5 * step - delta;
1511        let value_exact = base_value + 0.5 * step;
1512        let value_above = base_value + 0.5 * step + delta;
1513        let price_below = currency_pair_btcusdt.make_price(value_below);
1514        let price_exact = currency_pair_btcusdt.make_price(value_exact);
1515        let price_above = currency_pair_btcusdt.make_price(value_above);
1516        assert_eq!(price_below, price_exact);
1517        assert_ne!(price_exact, price_above);
1518    }
1519
1520    #[rstest]
1521    #[case(dec!(1.234999), dec!(1.23))]
1522    #[case(dec!(1.235), dec!(1.24))]
1523    #[case(dec!(1.235001), dec!(1.24))]
1524    #[case(dec!(10000.0), dec!(10000.0))]
1525    fn make_price_from_decimal_matches_f64_path(
1526        currency_pair_btcusdt: CurrencyPair,
1527        #[case] value: Decimal,
1528        #[case] expected: Decimal,
1529    ) {
1530        let from_decimal = currency_pair_btcusdt.make_price_from_decimal(value);
1531        let from_f64 = currency_pair_btcusdt.make_price(value.to_string().parse::<f64>().unwrap());
1532        assert_eq!(from_decimal, from_f64);
1533        assert_eq!(from_decimal.as_decimal(), expected);
1534    }
1535
1536    #[rstest]
1537    fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
1538        assert!(ethbtc_quanto.is_quanto());
1539    }
1540
1541    #[rstest]
1542    fn notional_quanto(ethbtc_quanto: CryptoFuture) {
1543        let quantity = ethbtc_quanto.make_qty(5.0, None);
1544        let price = ethbtc_quanto.make_price(0.036);
1545        let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
1546        let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
1547        assert_eq!(notional, expected);
1548    }
1549
1550    #[rstest]
1551    #[case("USD", "BUSD")]
1552    #[case("USD", "FDUSD")]
1553    #[case("USD", "pUSD")]
1554    #[case("USD", "TUSD")]
1555    #[case("USD", "USD")]
1556    #[case("USD", "USDC")]
1557    #[case("USD", "USDC.e")]
1558    #[case("USD", "USDP")]
1559    #[case("USD", "USDT")]
1560    #[case("BUSD", "USD")]
1561    #[case("FDUSD", "USD")]
1562    #[case("pUSD", "USD")]
1563    #[case("TUSD", "USD")]
1564    #[case("USDC", "USD")]
1565    #[case("USDC.e", "USD")]
1566    #[case("USDP", "USD")]
1567    #[case("USDT", "USD")]
1568    fn usd_equivalent_settlement_is_not_quanto(
1569        #[case] quote_currency_code: &str,
1570        #[case] settlement_currency_code: &str,
1571    ) {
1572        let quote_currency =
1573            Currency::try_from_str(quote_currency_code).expect("quote currency must exist");
1574        let settlement_currency = Currency::try_from_str(settlement_currency_code)
1575            .expect("settlement currency must exist");
1576        let instrument = crypto_future_with_quote_settlement(quote_currency, settlement_currency);
1577        let quantity = instrument.make_qty(5.0, None);
1578        let price = instrument.make_price(1000.0);
1579        let notional = instrument.calculate_notional_value(quantity, price, None);
1580
1581        assert!(!instrument.is_quanto());
1582        assert_eq!(instrument.cost_currency(), quote_currency);
1583        assert_eq!(notional, Money::new(5000.0, quote_currency));
1584    }
1585
1586    #[rstest]
1587    fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
1588        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1589        let price = xbtusd_inverse_perp.make_price(50_000.0);
1590        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
1591        let expected = Money::new(
1592            100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
1593            xbtusd_inverse_perp.base_currency().unwrap(),
1594        );
1595        assert_eq!(notional, expected);
1596    }
1597
1598    #[rstest]
1599    fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
1600        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1601        let price = xbtusd_inverse_perp.make_price(50_000.0);
1602        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
1603        let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
1604        assert_eq!(notional, expected);
1605    }
1606
1607    #[rstest]
1608    fn try_notional_inverse_zero_price_returns_error(xbtusd_inverse_perp: CryptoPerpetual) {
1609        let result = xbtusd_inverse_perp.try_calculate_notional_value(
1610            xbtusd_inverse_perp.make_qty(100.0, None),
1611            Price::new(0.0, 1),
1612            Some(false),
1613        );
1614
1615        assert_eq!(
1616            result.unwrap_err().to_string(),
1617            "price must be positive for inverse notional valuation"
1618        );
1619    }
1620
1621    #[rstest]
1622    fn try_notional_unrepresentable_money_returns_error(currency_pair_btcusdt: CurrencyPair) {
1623        let result = currency_pair_btcusdt.try_calculate_notional_value(
1624            Quantity::from("100000000"),
1625            Price::from("100000000"),
1626            None,
1627        );
1628
1629        assert!(result.is_err());
1630    }
1631
1632    #[rstest]
1633    fn try_notional_decimal_overflow_returns_error() {
1634        let result = try_notional_value(
1635            Quantity::from("9000000000"),
1636            Price::from("9000000000"),
1637            Quantity::from("9000000000"),
1638            false,
1639            false,
1640            Currency::USD(),
1641        );
1642
1643        assert_eq!(
1644            result.unwrap_err().to_string(),
1645            "notional calculation overflow"
1646        );
1647    }
1648
1649    #[rstest]
1650    fn validate_non_positive_max_price() {
1651        let size_increment = Quantity::new(0.01, 2);
1652        let multiplier = Quantity::new(1.0, 0);
1653        let max_price = Price::new(0.0, 2);
1654        let error = validate_instrument_common(
1655            2,
1656            2,
1657            size_increment,
1658            multiplier,
1659            dec!(0.01),
1660            dec!(0.01),
1661            None,
1662            None,
1663            None,
1664            None,
1665            None,
1666            None,
1667            Some(max_price),
1668            None,
1669        )
1670        .unwrap_err();
1671
1672        assert_eq!(
1673            error,
1674            CorrectnessError::NotPositive {
1675                param: "max_price".to_string(),
1676                value: "0.00".to_string(),
1677                type_name: "`Price`",
1678            }
1679        );
1680    }
1681
1682    #[rstest]
1683    fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1684        let size_increment = Quantity::new(0.01, 2);
1685        let multiplier = Quantity::new(1.0, 0);
1686        let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1687        let error = validate_instrument_common(
1688            2,
1689            2,
1690            size_increment,
1691            multiplier,
1692            dec!(0.01),
1693            dec!(0.01),
1694            None,
1695            None,
1696            None,
1697            None,
1698            Some(max_notional),
1699            None,
1700            None,
1701            None,
1702        )
1703        .unwrap_err();
1704
1705        assert_eq!(
1706            error,
1707            CorrectnessError::NotPositive {
1708                param: "max_notional".to_string(),
1709                value: "0.00000000 USDT".to_string(),
1710                type_name: "`Money`",
1711            }
1712        );
1713    }
1714
1715    #[rstest]
1716    fn validate_price_increment_min_price_precision_mismatch() {
1717        let size_increment = Quantity::new(0.01, 2);
1718        let multiplier = Quantity::new(1.0, 0);
1719        let price_increment = Price::new(0.01, 2);
1720        let min_price = Price::new(1.0, 3);
1721        let error = validate_instrument_common(
1722            2,
1723            2,
1724            size_increment,
1725            multiplier,
1726            dec!(0.01),
1727            dec!(0.01),
1728            Some(price_increment),
1729            None,
1730            None,
1731            None,
1732            None,
1733            None,
1734            None,
1735            Some(min_price),
1736        )
1737        .unwrap_err();
1738
1739        assert_eq!(
1740            error,
1741            CorrectnessError::EqualityMismatch {
1742                lhs_param: "min_price.precision".to_string(),
1743                rhs_param: "price_precision".to_string(),
1744                lhs: "3".to_string(),
1745                rhs: "2".to_string(),
1746                type_name: "u8",
1747            }
1748        );
1749    }
1750
1751    #[rstest]
1752    fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1753        let size_increment = Quantity::new(0.01, 2);
1754        let multiplier = Quantity::new(1.0, 0);
1755        let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1756        let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1757        let error = validate_instrument_common(
1758            2,
1759            2,
1760            size_increment,
1761            multiplier,
1762            dec!(0.01),
1763            dec!(0.01),
1764            None,
1765            None,
1766            None,
1767            None,
1768            Some(max_notional),
1769            Some(min_notional),
1770            None,
1771            None,
1772        )
1773        .unwrap_err();
1774
1775        assert_eq!(
1776            error,
1777            CorrectnessError::NotPositive {
1778                param: "min_notional".to_string(),
1779                value: "-1.00000000 USDT".to_string(),
1780                type_name: "`Money`",
1781            }
1782        );
1783    }
1784
1785    #[rstest]
1786    #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1787    #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1788    #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1789    #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1790    #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1791    #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1792    #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1793    #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1794    #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1795    fn base_qty_rounding(
1796        currency_pair_btcusdt: CurrencyPair,
1797        #[case] q: Decimal,
1798        #[case] px: Decimal,
1799        #[case] expected: f64,
1800    ) {
1801        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1802        let price = Price::new(px.to_f64().unwrap(), 8);
1803        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1804        assert!((base.as_f64() - expected).abs() < 1e-9);
1805    }
1806
1807    proptest! {
1808        #[rstest]
1809        fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1810            let instrument = currency_pair_btcusdt();
1811            let price = instrument.make_price(input);
1812            prop_assert!(price.as_f64().is_finite());
1813            let quantity = instrument.make_qty(input, None);
1814            prop_assert!(quantity.as_f64().is_finite());
1815        }
1816    }
1817
1818    #[rstest]
1819    fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1820        if let Some(max_price) = currency_pair_btcusdt.max_price() {
1821            assert!(
1822                currency_pair_btcusdt
1823                    .next_ask_price(max_price.as_f64(), 1)
1824                    .is_none()
1825            );
1826        }
1827    }
1828
1829    #[rstest]
1830    fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1831        if let Some(max_price) = currency_pair_ethusdt.max_price() {
1832            assert!(
1833                currency_pair_ethusdt
1834                    .next_ask_price(max_price.as_f64(), 1)
1835                    .is_none()
1836            );
1837        }
1838    }
1839
1840    #[rstest]
1841    fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1842        if let Some(min_price) = currency_pair_btcusdt.min_price() {
1843            assert!(
1844                currency_pair_btcusdt
1845                    .next_bid_price(min_price.as_f64(), 1)
1846                    .is_none()
1847            );
1848        }
1849    }
1850
1851    #[rstest]
1852    fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1853        if let Some(min_price) = currency_pair_ethusdt.min_price() {
1854            assert!(
1855                currency_pair_ethusdt
1856                    .next_bid_price(min_price.as_f64(), 1)
1857                    .is_none()
1858            );
1859        }
1860    }
1861
1862    #[rstest]
1863    fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1864        if let Some(max_price) = ethbtc_quanto.max_price() {
1865            assert!(
1866                ethbtc_quanto
1867                    .next_ask_price(max_price.as_f64(), 1)
1868                    .is_none()
1869            );
1870        }
1871    }
1872
1873    #[rstest]
1874    #[case(0.999_999, false)]
1875    #[case(0.999_999, true)]
1876    #[case(1.000_000_1, false)]
1877    #[case(1.000_000_1, true)]
1878    #[case(1.234_5, false)]
1879    #[case(1.234_5, true)]
1880    #[case(2.345_5, false)]
1881    #[case(2.345_5, true)]
1882    #[case(0.000_999_999, false)]
1883    #[case(0.000_999_999, true)]
1884    fn quantity_rounding_grid(
1885        currency_pair_btcusdt: CurrencyPair,
1886        #[case] input: f64,
1887        #[case] round_down: bool,
1888    ) {
1889        let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1890        assert!(qty.as_f64().is_finite());
1891    }
1892
1893    #[rstest]
1894    fn validate_price_increment_max_price_precision_mismatch() {
1895        let size_increment = Quantity::new(0.01, 2);
1896        let multiplier = Quantity::new(1.0, 0);
1897        let price_increment = Price::new(0.01, 2);
1898        let max_price = Price::new(1.0, 3);
1899        let error = validate_instrument_common(
1900            2,
1901            2,
1902            size_increment,
1903            multiplier,
1904            dec!(0.01),
1905            dec!(0.01),
1906            Some(price_increment),
1907            None,
1908            None,
1909            None,
1910            None,
1911            None,
1912            Some(max_price),
1913            None,
1914        )
1915        .unwrap_err();
1916
1917        assert_eq!(
1918            error,
1919            CorrectnessError::EqualityMismatch {
1920                lhs_param: "max_price.precision".to_string(),
1921                rhs_param: "price_precision".to_string(),
1922                lhs: "3".to_string(),
1923                rhs: "2".to_string(),
1924                type_name: "u8",
1925            }
1926        );
1927    }
1928
1929    #[rstest]
1930    #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1931    #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1932    #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1933    #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1934    #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1935    #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1936    #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1937    #[case::dp16(
1938        Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1939        Decimal::new(2, 0),
1940        500.0
1941    )]
1942    #[case::dp17(
1943        Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1944        Decimal::new(2, 0),
1945        500.0
1946    )]
1947    fn base_qty_rounding_high_dp(
1948        currency_pair_btcusdt: CurrencyPair,
1949        #[case] q: Decimal,
1950        #[case] px: Decimal,
1951        #[case] expected: f64,
1952    ) {
1953        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1954        let price = Price::new(px.to_f64().unwrap(), 8);
1955        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1956        assert!((base.as_f64() - expected).abs() < 1e-9);
1957    }
1958
1959    #[rstest]
1960    fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1961        let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1962        assert!(check_positive_money(money, "money").is_ok());
1963    }
1964
1965    #[rstest]
1966    #[should_panic(expected = "NotPositive")]
1967    fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1968        let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1969        check_positive_money(money, "money").unwrap();
1970    }
1971
1972    #[rstest]
1973    #[should_panic(expected = "NotPositive")]
1974    fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1975        let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1976        check_positive_money(money, "money").unwrap();
1977    }
1978
1979    fn crypto_future_with_quote_settlement(
1980        quote_currency: Currency,
1981        settlement_currency: Currency,
1982    ) -> CryptoFuture {
1983        CryptoFuture::builder()
1984            .instrument_id(InstrumentId::from("ETHUSD-QUANTO-TEST.BINANCE"))
1985            .raw_symbol(Symbol::from("ETHUSD-QUANTO-TEST"))
1986            .underlying(Currency::ETH())
1987            .quote_currency(quote_currency)
1988            .settlement_currency(settlement_currency)
1989            .is_inverse(false)
1990            .activation_ns(0.into())
1991            .expiration_ns(0.into())
1992            .price_precision(2)
1993            .size_precision(0)
1994            .price_increment(Price::from("0.01"))
1995            .size_increment(Quantity::from("1"))
1996            .ts_event(0.into())
1997            .ts_init(0.into())
1998            .build()
1999            .unwrap()
2000    }
2001
2002    #[rstest]
2003    fn make_price_with_trailing_zeros_in_increment() {
2004        // Test instrument with price_increment 0.50 (precision 2, but min_increment_precision 1)
2005        // This verifies that trailing zeros in price_increment are handled correctly
2006        let instrument = CurrencyPair::builder()
2007            .instrument_id(InstrumentId::from("TEST.VENUE"))
2008            .raw_symbol(Symbol::from("TEST"))
2009            .base_currency(Currency::from("BTC"))
2010            .quote_currency(Currency::from("USD"))
2011            .price_precision(2)
2012            .size_precision(2)
2013            // price_increment with trailing zero
2014            .price_increment(Price::new(0.50, 2))
2015            .size_increment(Quantity::from("0.01"))
2016            .ts_event(UnixNanos::default())
2017            .ts_init(UnixNanos::default())
2018            .build()
2019            .unwrap();
2020
2021        // Verify min_increment_precision is 1 (ignoring trailing zero)
2022        assert_eq!(instrument.min_price_increment_precision(), 1);
2023
2024        // Test that make_price rounds to min_increment_precision (1)
2025        // 1.234 should round to 1.2 (not 1.23)
2026        let price = instrument.make_price(1.234);
2027        assert_eq!(price.as_f64(), 1.2);
2028
2029        // 1.25 should round to 1.2 (half-even rounding)
2030        let price = instrument.make_price(1.25);
2031        assert_eq!(price.as_f64(), 1.2);
2032
2033        // 1.35 should round to 1.4 (half-even rounding)
2034        let price = instrument.make_price(1.35);
2035        assert_eq!(price.as_f64(), 1.4);
2036
2037        // But output precision should still be 2
2038        assert_eq!(price.precision, 2);
2039    }
2040
2041    #[rstest]
2042    fn make_qty_with_trailing_zeros_in_increment() {
2043        // Test instrument with size_increment 0.50 (precision 2, but min_increment_precision 1)
2044        let instrument = CurrencyPair::builder()
2045            .instrument_id(InstrumentId::from("TEST.VENUE"))
2046            .raw_symbol(Symbol::from("TEST"))
2047            .base_currency(Currency::from("BTC"))
2048            .quote_currency(Currency::from("USD"))
2049            .price_precision(2)
2050            .size_precision(2)
2051            .price_increment(Price::new(0.01, 2))
2052            // size_increment with trailing zero
2053            .size_increment(Quantity::new(0.50, 2))
2054            .ts_event(UnixNanos::default())
2055            .ts_init(UnixNanos::default())
2056            .build()
2057            .unwrap();
2058
2059        // Verify min_increment_precision is 1 (ignoring trailing zero)
2060        assert_eq!(instrument.min_size_increment_precision(), 1);
2061
2062        // Test that make_qty rounds to min_increment_precision (1)
2063        // 1.234 should round to 1.2 (not 1.23)
2064        let qty = instrument.make_qty(1.234, None);
2065        assert_eq!(qty.as_f64(), 1.2);
2066
2067        // 1.25 should round to 1.2 (half-even rounding)
2068        let qty = instrument.make_qty(1.25, None);
2069        assert_eq!(qty.as_f64(), 1.2);
2070
2071        // 1.35 should round to 1.4 (half-even rounding)
2072        let qty = instrument.make_qty(1.35, None);
2073        assert_eq!(qty.as_f64(), 1.4);
2074
2075        // But output precision should still be 2
2076        assert_eq!(qty.precision, 2);
2077
2078        // Test round_down option
2079        let qty = instrument.make_qty(1.99, Some(true));
2080        assert_eq!(qty.as_f64(), 1.9);
2081    }
2082
2083    #[rstest]
2084    #[case(InstrumentClass::Future, true)]
2085    #[case(InstrumentClass::FuturesSpread, true)]
2086    #[case(InstrumentClass::Option, true)]
2087    #[case(InstrumentClass::OptionSpread, true)]
2088    #[case(InstrumentClass::Spot, false)]
2089    #[case(InstrumentClass::Swap, false)]
2090    #[case(InstrumentClass::Forward, false)]
2091    #[case(InstrumentClass::Cfd, false)]
2092    #[case(InstrumentClass::Bond, false)]
2093    #[case(InstrumentClass::Warrant, false)]
2094    #[case(InstrumentClass::SportsBetting, false)]
2095    #[case(InstrumentClass::BinaryOption, false)]
2096    fn test_instrument_class_has_expiration(
2097        #[case] instrument_class: InstrumentClass,
2098        #[case] expected: bool,
2099    ) {
2100        assert_eq!(instrument_class.has_expiration(), expected);
2101    }
2102}