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_dec = 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_dec = 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_dec / price_dec).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_CLASSES: [InstrumentClass; 4] = [
542    InstrumentClass::Future,
543    InstrumentClass::FuturesSpread,
544    InstrumentClass::Option,
545    InstrumentClass::OptionSpread,
546];
547
548#[cfg(test)]
549mod tests {
550    use std::str::FromStr;
551
552    use proptest::prelude::*;
553    use rstest::rstest;
554    use rust_decimal::Decimal;
555
556    use super::*;
557    use crate::{instruments::stubs::*, types::Money};
558
559    pub fn default_price_increment(precision: u8) -> Price {
560        let step = 10f64.powi(-(precision as i32));
561        Price::new(step, precision)
562    }
563
564    #[rstest]
565    fn default_increment_precision() {
566        let inc = default_price_increment(2);
567        assert_eq!(inc, Price::new(0.01, 2));
568    }
569
570    #[rstest]
571    #[case(1.5, "1.500000")]
572    #[case(2.5, "2.500000")]
573    #[case(1.2345678, "1.234568")]
574    #[case(0.000123, "0.000123")]
575    #[case(99999.999999, "99999.999999")]
576    fn make_qty_rounding(
577        currency_pair_btcusdt: CurrencyPair,
578        #[case] input: f64,
579        #[case] expected: &str,
580    ) {
581        assert_eq!(
582            currency_pair_btcusdt.make_qty(input, None).to_string(),
583            expected
584        );
585    }
586
587    #[rstest]
588    #[case(1.2345678, "1.234567")]
589    #[case(1.9999999, "1.999999")]
590    #[case(0.00012345, "0.000123")]
591    #[case(10.9999999, "10.999999")]
592    fn make_qty_round_down(
593        currency_pair_btcusdt: CurrencyPair,
594        #[case] input: f64,
595        #[case] expected: &str,
596    ) {
597        assert_eq!(
598            currency_pair_btcusdt
599                .make_qty(input, Some(true))
600                .to_string(),
601            expected
602        );
603    }
604
605    #[rstest]
606    #[case(1.2345678, "1.23457")]
607    #[case(2.3456781, "2.34568")]
608    #[case(0.00001, "0.00001")]
609    fn make_qty_precision(
610        currency_pair_ethusdt: CurrencyPair,
611        #[case] input: f64,
612        #[case] expected: &str,
613    ) {
614        assert_eq!(
615            currency_pair_ethusdt.make_qty(input, None).to_string(),
616            expected
617        );
618    }
619
620    #[rstest]
621    #[case(1.2345675, "1.234568")]
622    #[case(1.2345665, "1.234566")]
623    fn make_qty_half_even(
624        currency_pair_btcusdt: CurrencyPair,
625        #[case] input: f64,
626        #[case] expected: &str,
627    ) {
628        assert_eq!(
629            currency_pair_btcusdt.make_qty(input, None).to_string(),
630            expected
631        );
632    }
633
634    #[rstest]
635    #[should_panic]
636    fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
637        currency_pair_btcusdt.make_qty(1e-12, None);
638    }
639
640    #[rstest]
641    fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
642        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
643        let price = currency_pair_btcusdt.make_price(10_000.0);
644        let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
645        let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
646        assert_eq!(notional, expected);
647    }
648
649    #[rstest]
650    fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
651        let start = 10_000.123_4;
652        let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
653        let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
654        assert!(bid_1 < bid_0);
655        let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
656        assert_eq!(asks.len(), 3);
657        assert!(asks[0] > bid_0);
658    }
659
660    #[rstest]
661    #[should_panic]
662    fn validate_negative_margin_init() {
663        let size_increment = Quantity::new(0.01, 2);
664        let multiplier = Quantity::new(1.0, 0);
665
666        validate_instrument_common(
667            2,
668            2,              // size_precision
669            size_increment, // size_increment
670            multiplier,     // multiplier
671            dec!(-0.01),    // margin_init
672            dec!(0.01),     // margin_maint
673            None,           // price_increment
674            None,           // lot_size
675            None,           // max_quantity
676            None,           // min_quantity
677            None,           // max_notional
678            None,           // min_notional
679            None,           // max_price
680            None,           // min_price
681        )
682        .unwrap();
683    }
684
685    #[rstest]
686    #[should_panic]
687    fn validate_negative_margin_maint() {
688        let size_increment = Quantity::new(0.01, 2);
689        let multiplier = Quantity::new(1.0, 0);
690
691        validate_instrument_common(
692            2,
693            2,              // size_precision
694            size_increment, // size_increment
695            multiplier,     // multiplier
696            dec!(0.01),     // margin_init
697            dec!(-0.01),    // margin_maint
698            None,           // price_increment
699            None,           // lot_size
700            None,           // max_quantity
701            None,           // min_quantity
702            None,           // max_notional
703            None,           // min_notional
704            None,           // max_price
705            None,           // min_price
706        )
707        .unwrap();
708    }
709
710    #[rstest]
711    #[should_panic]
712    fn validate_negative_max_qty() {
713        let quantity = Quantity::new(0.0, 0);
714        validate_instrument_common(
715            2,
716            2,
717            Quantity::new(0.01, 2),
718            Quantity::new(1.0, 0),
719            dec!(0),
720            dec!(0),
721            None,
722            None,
723            Some(quantity),
724            None,
725            None,
726            None,
727            None,
728            None,
729        )
730        .unwrap();
731    }
732
733    #[rstest]
734    fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
735        let price = currency_pair_ethusdt.make_price(-123.456_789);
736        assert!(price.as_f64() < 0.0);
737    }
738
739    #[rstest]
740    fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
741        let quantity = currency_pair_btcusdt.make_qty(2.0, None);
742        let price = currency_pair_btcusdt.make_price(10_000.0);
743        let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
744        assert_eq!(base.to_string(), "0.000200");
745    }
746
747    #[rstest]
748    fn fixed_tick_scheme_prices() {
749        let scheme = FixedTickScheme::new(0.5).unwrap();
750        let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
751        let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
752        assert!(bid < ask);
753    }
754
755    #[rstest]
756    #[should_panic]
757    fn fixed_tick_negative() {
758        FixedTickScheme::new(-0.01).unwrap();
759    }
760
761    #[rstest]
762    fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
763        let start = 10_000.0;
764        let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
765        assert_eq!(bids.len(), 5);
766        for i in 1..bids.len() {
767            assert!(bids[i] < bids[i - 1]);
768        }
769    }
770
771    #[rstest]
772    fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
773        let start = 10_000.0;
774        let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
775        assert_eq!(asks.len(), 5);
776        for i in 1..asks.len() {
777            assert!(asks[i] > asks[i - 1]);
778        }
779    }
780
781    #[rstest]
782    fn fixed_tick_boundary() {
783        let scheme = FixedTickScheme::new(0.5).unwrap();
784        let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
785        assert_eq!(price, Price::new(10.5, 2));
786    }
787
788    #[rstest]
789    #[should_panic]
790    fn validate_price_increment_precision_mismatch() {
791        let size_increment = Quantity::new(0.01, 2);
792        let multiplier = Quantity::new(1.0, 0);
793        let price_increment = Price::new(0.001, 3);
794        validate_instrument_common(
795            2,
796            2,
797            size_increment,
798            multiplier,
799            dec!(0),
800            dec!(0),
801            Some(price_increment),
802            None,
803            None,
804            None,
805            None,
806            None,
807            None,
808            None,
809        )
810        .unwrap();
811    }
812
813    #[rstest]
814    #[should_panic]
815    fn validate_min_price_exceeds_max_price() {
816        let size_increment = Quantity::new(0.01, 2);
817        let multiplier = Quantity::new(1.0, 0);
818        let min_price = Price::new(10.0, 2);
819        let max_price = Price::new(5.0, 2);
820        validate_instrument_common(
821            2,
822            2,
823            size_increment,
824            multiplier,
825            dec!(0),
826            dec!(0),
827            None,
828            None,
829            None,
830            None,
831            None,
832            None,
833            Some(max_price),
834            Some(min_price),
835        )
836        .unwrap();
837    }
838
839    #[rstest]
840    fn validate_instrument_common_ok() {
841        let res = validate_instrument_common(
842            2,
843            4,
844            Quantity::new(0.0001, 4),
845            Quantity::new(1.0, 0),
846            dec!(0.02),
847            dec!(0.01),
848            Some(Price::new(0.01, 2)),
849            None,
850            None,
851            None,
852            None,
853            None,
854            None,
855            None,
856        );
857        assert!(matches!(res, Ok(())));
858    }
859
860    #[rstest]
861    #[should_panic]
862    fn validate_multiple_errors() {
863        validate_instrument_common(
864            2,
865            2,
866            Quantity::new(-0.01, 2),
867            Quantity::new(0.0, 0),
868            dec!(0),
869            dec!(0),
870            None,
871            None,
872            None,
873            None,
874            None,
875            None,
876            None,
877            None,
878        )
879        .unwrap();
880    }
881
882    #[rstest]
883    #[case(1.234_999_9, false, "1.235000")]
884    #[case(1.234_999_9, true, "1.234999")]
885    fn make_qty_boundary(
886        currency_pair_btcusdt: CurrencyPair,
887        #[case] input: f64,
888        #[case] round_down: bool,
889        #[case] expected: &str,
890    ) {
891        let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
892        assert_eq!(quantity.to_string(), expected);
893    }
894
895    #[rstest]
896    fn fixed_tick_multiple_steps() {
897        let scheme = FixedTickScheme::new(1.0).unwrap();
898        let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
899        let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
900        assert_eq!(bid, Price::new(8.0, 1));
901        assert_eq!(ask, Price::new(13.0, 1));
902    }
903
904    #[rstest]
905    #[case(1.234_999, 1.23)]
906    #[case(1.235, 1.24)]
907    #[case(1.235_001, 1.24)]
908    fn make_price_rounding_parity(
909        currency_pair_btcusdt: CurrencyPair,
910        #[case] input: f64,
911        #[case] expected: f64,
912    ) {
913        let price = currency_pair_btcusdt.make_price(input);
914        assert!((price.as_f64() - expected).abs() < 1e-9);
915    }
916
917    #[rstest]
918    fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
919        let rounding_precision = std::cmp::min(
920            currency_pair_btcusdt.price_precision(),
921            currency_pair_btcusdt._min_price_increment_precision(),
922        );
923        let step = 10f64.powi(-(rounding_precision as i32));
924        let base_even_multiple = 42.0;
925        let base_value = step * base_even_multiple;
926        let delta = step / 2000.0;
927        let value_below = base_value + 0.5 * step - delta;
928        let value_exact = base_value + 0.5 * step;
929        let value_above = base_value + 0.5 * step + delta;
930        let price_below = currency_pair_btcusdt.make_price(value_below);
931        let price_exact = currency_pair_btcusdt.make_price(value_exact);
932        let price_above = currency_pair_btcusdt.make_price(value_above);
933        assert_eq!(price_below, price_exact);
934        assert_ne!(price_exact, price_above);
935    }
936
937    #[rstest]
938    fn tick_scheme_round_trip() {
939        let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
940        assert_eq!(scheme.to_string(), "CRYPTO_0_01");
941    }
942
943    #[rstest]
944    fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
945        assert!(ethbtc_quanto.is_quanto());
946    }
947
948    #[rstest]
949    fn notional_quanto(ethbtc_quanto: CryptoFuture) {
950        let quantity = ethbtc_quanto.make_qty(5.0, None);
951        let price = ethbtc_quanto.make_price(0.036);
952        let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
953        let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
954        assert_eq!(notional, expected);
955    }
956
957    #[rstest]
958    fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
959        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
960        let price = xbtusd_inverse_perp.make_price(50_000.0);
961        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
962        let expected = Money::new(
963            100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
964            xbtusd_inverse_perp.base_currency().unwrap(),
965        );
966        assert_eq!(notional, expected);
967    }
968
969    #[rstest]
970    fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
971        let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
972        let price = xbtusd_inverse_perp.make_price(50_000.0);
973        let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
974        let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
975        assert_eq!(notional, expected);
976    }
977
978    #[rstest]
979    #[should_panic]
980    fn validate_non_positive_max_price() {
981        let size_increment = Quantity::new(0.01, 2);
982        let multiplier = Quantity::new(1.0, 0);
983        let max_price = Price::new(0.0, 2);
984        validate_instrument_common(
985            2,
986            2,
987            size_increment,
988            multiplier,
989            dec!(0),
990            dec!(0),
991            None,
992            None,
993            None,
994            None,
995            None,
996            None,
997            Some(max_price),
998            None,
999        )
1000        .unwrap();
1001    }
1002
1003    #[rstest]
1004    #[should_panic]
1005    fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1006        let size_increment = Quantity::new(0.01, 2);
1007        let multiplier = Quantity::new(1.0, 0);
1008        let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1009        validate_instrument_common(
1010            2,
1011            2,
1012            size_increment,
1013            multiplier,
1014            dec!(0),
1015            dec!(0),
1016            None,
1017            None,
1018            None,
1019            None,
1020            Some(max_notional),
1021            None,
1022            None,
1023            None,
1024        )
1025        .unwrap();
1026    }
1027
1028    #[rstest]
1029    #[should_panic]
1030    fn validate_price_increment_min_price_precision_mismatch() {
1031        let size_increment = Quantity::new(0.01, 2);
1032        let multiplier = Quantity::new(1.0, 0);
1033        let price_increment = Price::new(0.01, 2);
1034        let min_price = Price::new(1.0, 3);
1035        validate_instrument_common(
1036            2,
1037            2,
1038            size_increment,
1039            multiplier,
1040            dec!(0),
1041            dec!(0),
1042            Some(price_increment),
1043            None,
1044            None,
1045            None,
1046            None,
1047            None,
1048            None,
1049            Some(min_price),
1050        )
1051        .unwrap();
1052    }
1053
1054    #[rstest]
1055    #[should_panic]
1056    fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1057        let size_increment = Quantity::new(0.01, 2);
1058        let multiplier = Quantity::new(1.0, 0);
1059        let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1060        let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1061        validate_instrument_common(
1062            2,
1063            2,
1064            size_increment,
1065            multiplier,
1066            dec!(0),
1067            dec!(0),
1068            None,
1069            None,
1070            None,
1071            None,
1072            Some(max_notional),
1073            Some(min_notional),
1074            None,
1075            None,
1076        )
1077        .unwrap();
1078    }
1079
1080    #[rstest]
1081    #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1082    #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1083    #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1084    #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1085    #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1086    #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1087    #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1088    #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1089    #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1090    fn base_qty_rounding(
1091        currency_pair_btcusdt: CurrencyPair,
1092        #[case] q: Decimal,
1093        #[case] px: Decimal,
1094        #[case] expected: f64,
1095    ) {
1096        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1097        let price = Price::new(px.to_f64().unwrap(), 8);
1098        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1099        assert!((base.as_f64() - expected).abs() < 1e-9);
1100    }
1101
1102    proptest! {
1103        #[rstest]
1104        fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1105            let instrument = currency_pair_btcusdt();
1106            let price = instrument.make_price(input);
1107            prop_assert!(price.as_f64().is_finite());
1108            let quantity = instrument.make_qty(input, None);
1109            prop_assert!(quantity.as_f64().is_finite());
1110        }
1111    }
1112
1113    #[rstest]
1114    fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1115        if let Some(max_price) = currency_pair_btcusdt.max_price() {
1116            assert!(
1117                currency_pair_btcusdt
1118                    .next_ask_price(max_price.as_f64(), 1)
1119                    .is_none()
1120            );
1121        }
1122    }
1123
1124    #[rstest]
1125    fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1126        if let Some(max_price) = currency_pair_ethusdt.max_price() {
1127            assert!(
1128                currency_pair_ethusdt
1129                    .next_ask_price(max_price.as_f64(), 1)
1130                    .is_none()
1131            );
1132        }
1133    }
1134
1135    #[rstest]
1136    fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1137        if let Some(min_price) = currency_pair_btcusdt.min_price() {
1138            assert!(
1139                currency_pair_btcusdt
1140                    .next_bid_price(min_price.as_f64(), 1)
1141                    .is_none()
1142            );
1143        }
1144    }
1145
1146    #[rstest]
1147    fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1148        if let Some(min_price) = currency_pair_ethusdt.min_price() {
1149            assert!(
1150                currency_pair_ethusdt
1151                    .next_bid_price(min_price.as_f64(), 1)
1152                    .is_none()
1153            );
1154        }
1155    }
1156
1157    #[rstest]
1158    fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1159        if let Some(max_price) = ethbtc_quanto.max_price() {
1160            assert!(
1161                ethbtc_quanto
1162                    .next_ask_price(max_price.as_f64(), 1)
1163                    .is_none()
1164            );
1165        }
1166    }
1167
1168    #[rstest]
1169    #[case(0.999_999, false)]
1170    #[case(0.999_999, true)]
1171    #[case(1.000_000_1, false)]
1172    #[case(1.000_000_1, true)]
1173    #[case(1.234_5, false)]
1174    #[case(1.234_5, true)]
1175    #[case(2.345_5, false)]
1176    #[case(2.345_5, true)]
1177    #[case(0.000_999_999, false)]
1178    #[case(0.000_999_999, true)]
1179    fn quantity_rounding_grid(
1180        currency_pair_btcusdt: CurrencyPair,
1181        #[case] input: f64,
1182        #[case] round_down: bool,
1183    ) {
1184        let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1185        assert!(qty.as_f64().is_finite());
1186    }
1187
1188    #[rstest]
1189    fn pyo3_failure_tick_scheme_unknown() {
1190        assert!(TickScheme::from_str("UNKNOWN").is_err());
1191    }
1192
1193    #[rstest]
1194    fn pyo3_failure_fixed_tick_zero() {
1195        assert!(FixedTickScheme::new(0.0).is_err());
1196    }
1197
1198    #[rstest]
1199    fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1200        let size_increment = Quantity::new(0.01, 2);
1201        let multiplier = Quantity::new(1.0, 0);
1202        let price_increment = Price::new(0.01, 2);
1203        let max_price = Price::new(1.0, 3);
1204        let res = validate_instrument_common(
1205            2,
1206            2,
1207            size_increment,
1208            multiplier,
1209            dec!(0),
1210            dec!(0),
1211            Some(price_increment),
1212            None,
1213            None,
1214            None,
1215            None,
1216            None,
1217            Some(max_price),
1218            None,
1219        );
1220        assert!(res.is_err());
1221    }
1222
1223    #[rstest]
1224    #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1225    #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1226    #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1227    #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1228    #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1229    #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1230    #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1231    #[case::dp16(
1232        Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1233        Decimal::new(2, 0),
1234        500.0
1235    )]
1236    #[case::dp17(
1237        Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1238        Decimal::new(2, 0),
1239        500.0
1240    )]
1241    fn base_qty_rounding_high_dp(
1242        currency_pair_btcusdt: CurrencyPair,
1243        #[case] q: Decimal,
1244        #[case] px: Decimal,
1245        #[case] expected: f64,
1246    ) {
1247        let qty = Quantity::new(q.to_f64().unwrap(), 8);
1248        let price = Price::new(px.to_f64().unwrap(), 8);
1249        let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1250        assert!((base.as_f64() - expected).abs() < 1e-9);
1251    }
1252
1253    #[rstest]
1254    fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1255        let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1256        assert!(check_positive_money(money, "money").is_ok());
1257    }
1258
1259    #[rstest]
1260    #[should_panic]
1261    fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1262        let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1263        check_positive_money(money, "money").unwrap();
1264    }
1265
1266    #[rstest]
1267    #[should_panic]
1268    fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1269        let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1270        check_positive_money(money, "money").unwrap();
1271    }
1272}