nautilus_model/instruments/
mod.rs

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