Skip to main content

nautilus_model/instruments/
mod.rs

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