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