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