Skip to main content

nautilus_model/instruments/
mod.rs

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