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