nautilus_model/instruments/
mod.rs

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