1pub 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 TickScheme::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
202 TickScheme::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 TickScheme::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
214 TickScheme::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 TickScheme::Fixed(_) => write!(f, "FIXED"),
227 TickScheme::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(TickScheme::Fixed(FixedTickScheme::new(1.0)?)),
238 "CRYPTO_0_01" => Ok(TickScheme::Crypto),
239 _ => Err(anyhow::anyhow!("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 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 .map(|currency| currency != self.settlement_currency())
299 .unwrap_or(false)
300 }
301
302 fn price_precision(&self) -> u8;
303 fn size_precision(&self) -> u8;
304 fn price_increment(&self) -> Price;
305 fn size_increment(&self) -> Quantity;
306
307 fn multiplier(&self) -> Quantity;
308 fn lot_size(&self) -> Option<Quantity>;
309 fn max_quantity(&self) -> Option<Quantity>;
310 fn min_quantity(&self) -> Option<Quantity>;
311 fn max_notional(&self) -> Option<Money>;
312 fn min_notional(&self) -> Option<Money>;
313 fn max_price(&self) -> Option<Price>;
314 fn min_price(&self) -> Option<Price>;
315
316 fn margin_init(&self) -> Decimal {
317 dec!(0)
318 }
319 fn margin_maint(&self) -> Decimal {
320 dec!(0)
321 }
322 fn maker_fee(&self) -> Decimal {
323 dec!(0)
324 }
325 fn taker_fee(&self) -> Decimal {
326 dec!(0)
327 }
328
329 fn ts_event(&self) -> UnixNanos;
330 fn ts_init(&self) -> UnixNanos;
331
332 fn _min_price_increment_precision(&self) -> u8 {
333 self.price_increment().precision
334 }
335
336 #[inline(always)]
340 fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
341 check_predicate_true(value.is_finite(), "non-finite value passed to make_price")?;
342 let precision = self
343 .price_precision()
344 .min(self._min_price_increment_precision()) as u32;
345 let decimal_value = Decimal::from_f64_retain(value)
346 .ok_or_else(|| anyhow::anyhow!("non-finite value passed to make_price"))?;
347 let rounded_decimal =
348 decimal_value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
349 let rounded = rounded_decimal
350 .to_f64()
351 .ok_or_else(|| anyhow::anyhow!("Decimal out of f64 range in make_price"))?;
352 Ok(Price::new(rounded, self.price_precision()))
353 }
354
355 fn make_price(&self, value: f64) -> Price {
356 self.try_make_price(value).unwrap()
357 }
358
359 #[inline(always)]
363 fn try_make_qty(&self, value: f64, round_down: Option<bool>) -> anyhow::Result<Quantity> {
364 let precision_u8 = self.size_precision();
365 let precision = precision_u8 as u32;
366 let decimal_value = Decimal::from_f64_retain(value)
367 .ok_or_else(|| anyhow::anyhow!("non-finite value passed to make_qty"))?;
368 let rounded_decimal = if round_down.unwrap_or(false) {
369 decimal_value.round_dp_with_strategy(precision, RoundingStrategy::ToZero)
370 } else {
371 decimal_value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven)
372 };
373 let rounded = rounded_decimal
374 .to_f64()
375 .ok_or_else(|| anyhow::anyhow!("Decimal out of f64 range in make_qty"))?;
376 let increment = 10f64.powi(-(precision_u8 as i32));
377 if value > 0.0 && rounded < increment * 0.1 {
378 anyhow::bail!("value rounded to zero for quantity");
379 }
380 Ok(Quantity::new(rounded, precision_u8))
381 }
382
383 fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
384 self.try_make_qty(value, round_down).unwrap()
385 }
386
387 fn try_calculate_base_quantity(
391 &self,
392 quantity: Quantity,
393 last_price: Price,
394 ) -> anyhow::Result<Quantity> {
395 check_predicate_true(
396 quantity.as_f64().is_finite(),
397 "non-finite quantity passed to calculate_base_quantity",
398 )?;
399 check_predicate_true(
400 last_price.as_f64().is_finite(),
401 "non-finite price passed to calculate_base_quantity",
402 )?;
403 let quantity_decimal = Decimal::from_f64_retain(quantity.as_f64()).ok_or_else(|| {
404 anyhow::anyhow!("non-finite quantity passed to calculate_base_quantity")
405 })?;
406 let price_decimal = Decimal::from_f64_retain(last_price.as_f64())
407 .ok_or_else(|| anyhow::anyhow!("non-finite price passed to calculate_base_quantity"))?;
408 let value_decimal = (quantity_decimal / price_decimal).round_dp_with_strategy(
409 self.size_precision().into(),
410 RoundingStrategy::MidpointNearestEven,
411 );
412 let rounded = value_decimal.to_f64().ok_or_else(|| {
413 anyhow::anyhow!("Decimal out of f64 range in calculate_base_quantity")
414 })?;
415 Ok(Quantity::new(rounded, self.size_precision()))
416 }
417
418 fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
419 self.try_calculate_base_quantity(quantity, last_price)
420 .unwrap()
421 }
422
423 #[inline(always)]
427 fn calculate_notional_value(
428 &self,
429 quantity: Quantity,
430 price: Price,
431 use_quote_for_inverse: Option<bool>,
432 ) -> Money {
433 let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
434 if self.is_inverse() {
435 if use_quote_inverse {
436 Money::new(quantity.as_f64(), self.quote_currency())
437 } else {
438 let amount =
439 quantity.as_f64() * self.multiplier().as_f64() * (1.0 / price.as_f64());
440 let currency = self
441 .base_currency()
442 .expect("inverse instrument without base_currency");
443 Money::new(amount, currency)
444 }
445 } else if self.is_quanto() {
446 let amount = quantity.as_f64() * self.multiplier().as_f64() * price.as_f64();
447 Money::new(amount, self.settlement_currency())
448 } else {
449 let amount = quantity.as_f64() * self.multiplier().as_f64() * price.as_f64();
450 Money::new(amount, self.quote_currency())
451 }
452 }
453
454 #[inline(always)]
455 fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
456 let price = if let Some(scheme) = self.tick_scheme() {
457 scheme.next_bid_price(value, n, self.price_precision())?
458 } else {
459 let increment = self.price_increment().as_f64().abs();
460 if increment == 0.0 {
461 return None;
462 }
463 let base = (value / increment).floor() * increment;
464 Price::new(base - (n as f64) * increment, self.price_precision())
465 };
466 if self.min_price().is_some_and(|min| price < min)
467 || self.max_price().is_some_and(|max| price > max)
468 {
469 return None;
470 }
471 Some(price)
472 }
473
474 #[inline(always)]
475 fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
476 let price = if let Some(scheme) = self.tick_scheme() {
477 scheme.next_ask_price(value, n, self.price_precision())?
478 } else {
479 let increment = self.price_increment().as_f64().abs();
480 if increment == 0.0 {
481 return None;
482 }
483 let base = (value / increment).ceil() * increment;
484 Price::new(base + (n as f64) * increment, self.price_precision())
485 };
486 if self.min_price().is_some_and(|min| price < min)
487 || self.max_price().is_some_and(|max| price > max)
488 {
489 return None;
490 }
491 Some(price)
492 }
493
494 #[inline]
495 fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
496 let mut prices = Vec::with_capacity(n);
497 for i in 0..n {
498 if let Some(price) = self.next_bid_price(value, i as i32) {
499 prices.push(price);
500 } else {
501 break;
502 }
503 }
504 prices
505 }
506
507 #[inline]
508 fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
509 let mut prices = Vec::with_capacity(n);
510 for i in 0..n {
511 if let Some(price) = self.next_ask_price(value, i as i32) {
512 prices.push(price);
513 } else {
514 break;
515 }
516 }
517 prices
518 }
519}
520
521impl Display for CurrencyPair {
522 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523 write!(
524 f,
525 "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
526price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
527 stringify!(CurrencyPair),
528 self.id,
529 self.tick_scheme()
530 .map(|s| s.to_string())
531 .unwrap_or_else(|| "None".into()),
532 self.price_precision(),
533 self.size_precision(),
534 self.price_increment(),
535 self.size_increment(),
536 self.multiplier(),
537 self.margin_init(),
538 self.margin_maint(),
539 )
540 }
541}
542
543pub const EXPIRING_INSTRUMENT_TYPES: [InstrumentClass; 4] = [
544 InstrumentClass::Future,
545 InstrumentClass::FuturesSpread,
546 InstrumentClass::Option,
547 InstrumentClass::OptionSpread,
548];
549
550#[cfg(test)]
555mod tests {
556 use std::str::FromStr;
557
558 use proptest::prelude::*;
559 use rstest::rstest;
560 use rust_decimal::Decimal;
561
562 use super::*;
563 use crate::{instruments::stubs::*, types::Money};
564
565 pub fn default_price_increment(precision: u8) -> Price {
566 let step = 10f64.powi(-(precision as i32));
567 Price::new(step, precision)
568 }
569
570 #[rstest]
571 fn default_increment_precision() {
572 let inc = default_price_increment(2);
573 assert_eq!(inc, Price::new(0.01, 2));
574 }
575
576 #[rstest]
577 #[case(1.5, "1.500000")]
578 #[case(2.5, "2.500000")]
579 #[case(1.2345678, "1.234568")]
580 #[case(0.000123, "0.000123")]
581 #[case(99999.999999, "99999.999999")]
582 fn make_qty_rounding(
583 currency_pair_btcusdt: CurrencyPair,
584 #[case] input: f64,
585 #[case] expected: &str,
586 ) {
587 assert_eq!(
588 currency_pair_btcusdt.make_qty(input, None).to_string(),
589 expected
590 );
591 }
592
593 #[rstest]
594 #[case(1.2345678, "1.234567")]
595 #[case(1.9999999, "1.999999")]
596 #[case(0.00012345, "0.000123")]
597 #[case(10.9999999, "10.999999")]
598 fn make_qty_round_down(
599 currency_pair_btcusdt: CurrencyPair,
600 #[case] input: f64,
601 #[case] expected: &str,
602 ) {
603 assert_eq!(
604 currency_pair_btcusdt
605 .make_qty(input, Some(true))
606 .to_string(),
607 expected
608 );
609 }
610
611 #[rstest]
612 #[case(1.2345678, "1.23457")]
613 #[case(2.3456781, "2.34568")]
614 #[case(0.00001, "0.00001")]
615 fn make_qty_precision(
616 currency_pair_ethusdt: CurrencyPair,
617 #[case] input: f64,
618 #[case] expected: &str,
619 ) {
620 assert_eq!(
621 currency_pair_ethusdt.make_qty(input, None).to_string(),
622 expected
623 );
624 }
625
626 #[rstest]
627 #[case(1.2345675, "1.234568")]
628 #[case(1.2345665, "1.234566")]
629 fn make_qty_half_even(
630 currency_pair_btcusdt: CurrencyPair,
631 #[case] input: f64,
632 #[case] expected: &str,
633 ) {
634 assert_eq!(
635 currency_pair_btcusdt.make_qty(input, None).to_string(),
636 expected
637 );
638 }
639
640 #[rstest]
641 #[should_panic]
642 fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
643 currency_pair_btcusdt.make_qty(1e-12, None);
644 }
645
646 #[rstest]
647 fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
648 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
649 let price = currency_pair_btcusdt.make_price(10_000.0);
650 let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
651 let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
652 assert_eq!(notional, expected);
653 }
654
655 #[rstest]
656 fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
657 let start = 10_000.123_4;
658 let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
659 let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
660 assert!(bid_1 < bid_0);
661 let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
662 assert_eq!(asks.len(), 3);
663 assert!(asks[0] > bid_0);
664 }
665
666 #[rstest]
667 #[should_panic]
668 fn validate_negative_margin_init() {
669 let size_increment = Quantity::new(0.01, 2);
670 let multiplier = Quantity::new(1.0, 0);
671
672 validate_instrument_common(
673 2,
674 2, size_increment, multiplier, dec!(-0.01), dec!(0.01), None, None, None, None, None, None, None, None, )
688 .unwrap();
689 }
690
691 #[rstest]
692 #[should_panic]
693 fn validate_negative_margin_maint() {
694 let size_increment = Quantity::new(0.01, 2);
695 let multiplier = Quantity::new(1.0, 0);
696
697 validate_instrument_common(
698 2,
699 2, size_increment, multiplier, dec!(0.01), dec!(-0.01), None, None, None, None, None, None, None, None, )
713 .unwrap();
714 }
715
716 #[rstest]
717 #[should_panic]
718 fn validate_negative_max_qty() {
719 let quantity = Quantity::new(0.0, 0);
720 validate_instrument_common(
721 2,
722 2,
723 Quantity::new(0.01, 2),
724 Quantity::new(1.0, 0),
725 dec!(0),
726 dec!(0),
727 None,
728 None,
729 Some(quantity),
730 None,
731 None,
732 None,
733 None,
734 None,
735 )
736 .unwrap();
737 }
738
739 #[rstest]
740 fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
741 let price = currency_pair_ethusdt.make_price(-123.456_789);
742 assert!(price.as_f64() < 0.0);
743 }
744
745 #[rstest]
746 fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
747 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
748 let price = currency_pair_btcusdt.make_price(10_000.0);
749 let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
750 assert_eq!(base.to_string(), "0.000200");
751 }
752
753 #[rstest]
754 fn fixed_tick_scheme_prices() {
755 let scheme = FixedTickScheme::new(0.5).unwrap();
756 let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
757 let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
758 assert!(bid < ask);
759 }
760
761 #[rstest]
762 #[should_panic]
763 fn fixed_tick_negative() {
764 FixedTickScheme::new(-0.01).unwrap();
765 }
766
767 #[rstest]
768 fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
769 let start = 10_000.0;
770 let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
771 assert_eq!(bids.len(), 5);
772 for i in 1..bids.len() {
773 assert!(bids[i] < bids[i - 1]);
774 }
775 }
776
777 #[rstest]
778 fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
779 let start = 10_000.0;
780 let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
781 assert_eq!(asks.len(), 5);
782 for i in 1..asks.len() {
783 assert!(asks[i] > asks[i - 1]);
784 }
785 }
786
787 #[rstest]
788 fn fixed_tick_boundary() {
789 let scheme = FixedTickScheme::new(0.5).unwrap();
790 let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
791 assert_eq!(price, Price::new(10.5, 2));
792 }
793
794 #[rstest]
795 #[should_panic]
796 fn validate_price_increment_precision_mismatch() {
797 let size_increment = Quantity::new(0.01, 2);
798 let multiplier = Quantity::new(1.0, 0);
799 let price_increment = Price::new(0.001, 3);
800 validate_instrument_common(
801 2,
802 2,
803 size_increment,
804 multiplier,
805 dec!(0),
806 dec!(0),
807 Some(price_increment),
808 None,
809 None,
810 None,
811 None,
812 None,
813 None,
814 None,
815 )
816 .unwrap();
817 }
818
819 #[rstest]
820 #[should_panic]
821 fn validate_min_price_exceeds_max_price() {
822 let size_increment = Quantity::new(0.01, 2);
823 let multiplier = Quantity::new(1.0, 0);
824 let min_price = Price::new(10.0, 2);
825 let max_price = Price::new(5.0, 2);
826 validate_instrument_common(
827 2,
828 2,
829 size_increment,
830 multiplier,
831 dec!(0),
832 dec!(0),
833 None,
834 None,
835 None,
836 None,
837 None,
838 None,
839 Some(max_price),
840 Some(min_price),
841 )
842 .unwrap();
843 }
844
845 #[rstest]
846 fn validate_instrument_common_ok() {
847 let res = validate_instrument_common(
848 2,
849 4,
850 Quantity::new(0.0001, 4),
851 Quantity::new(1.0, 0),
852 dec!(0.02),
853 dec!(0.01),
854 Some(Price::new(0.01, 2)),
855 None,
856 None,
857 None,
858 None,
859 None,
860 None,
861 None,
862 );
863 assert!(matches!(res, Ok(())));
864 }
865
866 #[rstest]
867 #[should_panic]
868 fn validate_multiple_errors() {
869 validate_instrument_common(
870 2,
871 2,
872 Quantity::new(-0.01, 2),
873 Quantity::new(0.0, 0),
874 dec!(0),
875 dec!(0),
876 None,
877 None,
878 None,
879 None,
880 None,
881 None,
882 None,
883 None,
884 )
885 .unwrap();
886 }
887
888 #[rstest]
889 #[case(1.234_999_9, false, "1.235000")]
890 #[case(1.234_999_9, true, "1.234999")]
891 fn make_qty_boundary(
892 currency_pair_btcusdt: CurrencyPair,
893 #[case] input: f64,
894 #[case] round_down: bool,
895 #[case] expected: &str,
896 ) {
897 let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
898 assert_eq!(quantity.to_string(), expected);
899 }
900
901 #[rstest]
902 fn fixed_tick_multiple_steps() {
903 let scheme = FixedTickScheme::new(1.0).unwrap();
904 let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
905 let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
906 assert_eq!(bid, Price::new(8.0, 1));
907 assert_eq!(ask, Price::new(13.0, 1));
908 }
909
910 #[rstest]
911 #[case(1.234_999, 1.23)]
912 #[case(1.235, 1.24)]
913 #[case(1.235_001, 1.24)]
914 fn make_price_rounding_parity(
915 currency_pair_btcusdt: CurrencyPair,
916 #[case] input: f64,
917 #[case] expected: f64,
918 ) {
919 let price = currency_pair_btcusdt.make_price(input);
920 assert!((price.as_f64() - expected).abs() < 1e-9);
921 }
922
923 #[rstest]
924 fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
925 let rounding_precision = std::cmp::min(
926 currency_pair_btcusdt.price_precision(),
927 currency_pair_btcusdt._min_price_increment_precision(),
928 );
929 let step = 10f64.powi(-(rounding_precision as i32));
930 let base_even_multiple = 42.0;
931 let base_value = step * base_even_multiple;
932 let delta = step / 2000.0;
933 let value_below = base_value + 0.5 * step - delta;
934 let value_exact = base_value + 0.5 * step;
935 let value_above = base_value + 0.5 * step + delta;
936 let price_below = currency_pair_btcusdt.make_price(value_below);
937 let price_exact = currency_pair_btcusdt.make_price(value_exact);
938 let price_above = currency_pair_btcusdt.make_price(value_above);
939 assert_eq!(price_below, price_exact);
940 assert_ne!(price_exact, price_above);
941 }
942
943 #[rstest]
944 fn tick_scheme_round_trip() {
945 let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
946 assert_eq!(scheme.to_string(), "CRYPTO_0_01");
947 }
948
949 #[rstest]
950 fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
951 assert!(ethbtc_quanto.is_quanto());
952 }
953
954 #[rstest]
955 fn notional_quanto(ethbtc_quanto: CryptoFuture) {
956 let quantity = ethbtc_quanto.make_qty(5.0, None);
957 let price = ethbtc_quanto.make_price(0.036);
958 let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
959 let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
960 assert_eq!(notional, expected);
961 }
962
963 #[rstest]
964 fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
965 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
966 let price = xbtusd_inverse_perp.make_price(50_000.0);
967 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
968 let expected = Money::new(
969 100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
970 xbtusd_inverse_perp.base_currency().unwrap(),
971 );
972 assert_eq!(notional, expected);
973 }
974
975 #[rstest]
976 fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
977 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
978 let price = xbtusd_inverse_perp.make_price(50_000.0);
979 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
980 let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
981 assert_eq!(notional, expected);
982 }
983
984 #[rstest]
985 #[should_panic]
986 fn validate_non_positive_max_price() {
987 let size_increment = Quantity::new(0.01, 2);
988 let multiplier = Quantity::new(1.0, 0);
989 let max_price = Price::new(0.0, 2);
990 validate_instrument_common(
991 2,
992 2,
993 size_increment,
994 multiplier,
995 dec!(0),
996 dec!(0),
997 None,
998 None,
999 None,
1000 None,
1001 None,
1002 None,
1003 Some(max_price),
1004 None,
1005 )
1006 .unwrap();
1007 }
1008
1009 #[rstest]
1010 #[should_panic]
1011 fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1012 let size_increment = Quantity::new(0.01, 2);
1013 let multiplier = Quantity::new(1.0, 0);
1014 let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1015 validate_instrument_common(
1016 2,
1017 2,
1018 size_increment,
1019 multiplier,
1020 dec!(0),
1021 dec!(0),
1022 None,
1023 None,
1024 None,
1025 None,
1026 Some(max_notional),
1027 None,
1028 None,
1029 None,
1030 )
1031 .unwrap();
1032 }
1033
1034 #[rstest]
1035 #[should_panic]
1036 fn validate_price_increment_min_price_precision_mismatch() {
1037 let size_increment = Quantity::new(0.01, 2);
1038 let multiplier = Quantity::new(1.0, 0);
1039 let price_increment = Price::new(0.01, 2);
1040 let min_price = Price::new(1.0, 3);
1041 validate_instrument_common(
1042 2,
1043 2,
1044 size_increment,
1045 multiplier,
1046 dec!(0),
1047 dec!(0),
1048 Some(price_increment),
1049 None,
1050 None,
1051 None,
1052 None,
1053 None,
1054 None,
1055 Some(min_price),
1056 )
1057 .unwrap();
1058 }
1059
1060 #[rstest]
1061 #[should_panic]
1062 fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1063 let size_increment = Quantity::new(0.01, 2);
1064 let multiplier = Quantity::new(1.0, 0);
1065 let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1066 let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1067 validate_instrument_common(
1068 2,
1069 2,
1070 size_increment,
1071 multiplier,
1072 dec!(0),
1073 dec!(0),
1074 None,
1075 None,
1076 None,
1077 None,
1078 Some(max_notional),
1079 Some(min_notional),
1080 None,
1081 None,
1082 )
1083 .unwrap();
1084 }
1085
1086 #[rstest]
1087 #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1088 #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1089 #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1090 #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1091 #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1092 #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1093 #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1094 #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1095 #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1096 fn base_qty_rounding(
1097 currency_pair_btcusdt: CurrencyPair,
1098 #[case] q: Decimal,
1099 #[case] px: Decimal,
1100 #[case] expected: f64,
1101 ) {
1102 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1103 let price = Price::new(px.to_f64().unwrap(), 8);
1104 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1105 assert!((base.as_f64() - expected).abs() < 1e-9);
1106 }
1107
1108 proptest! {
1109 #[rstest]
1110 fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1111 let instrument = currency_pair_btcusdt();
1112 let price = instrument.make_price(input);
1113 prop_assert!(price.as_f64().is_finite());
1114 let quantity = instrument.make_qty(input, None);
1115 prop_assert!(quantity.as_f64().is_finite());
1116 }
1117 }
1118
1119 #[rstest]
1120 fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1121 if let Some(max_price) = currency_pair_btcusdt.max_price() {
1122 assert!(
1123 currency_pair_btcusdt
1124 .next_ask_price(max_price.as_f64(), 1)
1125 .is_none()
1126 );
1127 }
1128 }
1129
1130 #[rstest]
1131 fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1132 if let Some(max_price) = currency_pair_ethusdt.max_price() {
1133 assert!(
1134 currency_pair_ethusdt
1135 .next_ask_price(max_price.as_f64(), 1)
1136 .is_none()
1137 );
1138 }
1139 }
1140
1141 #[rstest]
1142 fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1143 if let Some(min_price) = currency_pair_btcusdt.min_price() {
1144 assert!(
1145 currency_pair_btcusdt
1146 .next_bid_price(min_price.as_f64(), 1)
1147 .is_none()
1148 );
1149 }
1150 }
1151
1152 #[rstest]
1153 fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1154 if let Some(min_price) = currency_pair_ethusdt.min_price() {
1155 assert!(
1156 currency_pair_ethusdt
1157 .next_bid_price(min_price.as_f64(), 1)
1158 .is_none()
1159 );
1160 }
1161 }
1162
1163 #[rstest]
1164 fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1165 if let Some(max_price) = ethbtc_quanto.max_price() {
1166 assert!(
1167 ethbtc_quanto
1168 .next_ask_price(max_price.as_f64(), 1)
1169 .is_none()
1170 );
1171 }
1172 }
1173
1174 #[rstest]
1175 #[case(0.999_999, false)]
1176 #[case(0.999_999, true)]
1177 #[case(1.000_000_1, false)]
1178 #[case(1.000_000_1, true)]
1179 #[case(1.234_5, false)]
1180 #[case(1.234_5, true)]
1181 #[case(2.345_5, false)]
1182 #[case(2.345_5, true)]
1183 #[case(0.000_999_999, false)]
1184 #[case(0.000_999_999, true)]
1185 fn quantity_rounding_grid(
1186 currency_pair_btcusdt: CurrencyPair,
1187 #[case] input: f64,
1188 #[case] round_down: bool,
1189 ) {
1190 let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1191 assert!(qty.as_f64().is_finite());
1192 }
1193
1194 #[rstest]
1195 fn pyo3_failure_tick_scheme_unknown() {
1196 assert!(TickScheme::from_str("UNKNOWN").is_err());
1197 }
1198
1199 #[rstest]
1200 fn pyo3_failure_fixed_tick_zero() {
1201 assert!(FixedTickScheme::new(0.0).is_err());
1202 }
1203
1204 #[rstest]
1205 fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1206 let size_increment = Quantity::new(0.01, 2);
1207 let multiplier = Quantity::new(1.0, 0);
1208 let price_increment = Price::new(0.01, 2);
1209 let max_price = Price::new(1.0, 3);
1210 let res = validate_instrument_common(
1211 2,
1212 2,
1213 size_increment,
1214 multiplier,
1215 dec!(0),
1216 dec!(0),
1217 Some(price_increment),
1218 None,
1219 None,
1220 None,
1221 None,
1222 None,
1223 Some(max_price),
1224 None,
1225 );
1226 assert!(res.is_err());
1227 }
1228
1229 #[rstest]
1230 #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1231 #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1232 #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1233 #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1234 #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1235 #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1236 #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1237 #[case::dp16(
1238 Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1239 Decimal::new(2, 0),
1240 500.0
1241 )]
1242 #[case::dp17(
1243 Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1244 Decimal::new(2, 0),
1245 500.0
1246 )]
1247 fn base_qty_rounding_high_dp(
1248 currency_pair_btcusdt: CurrencyPair,
1249 #[case] q: Decimal,
1250 #[case] px: Decimal,
1251 #[case] expected: f64,
1252 ) {
1253 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1254 let price = Price::new(px.to_f64().unwrap(), 8);
1255 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1256 assert!((base.as_f64() - expected).abs() < 1e-9);
1257 }
1258
1259 #[rstest]
1260 fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1261 let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1262 assert!(check_positive_money(money, "money").is_ok());
1263 }
1264
1265 #[rstest]
1266 #[should_panic]
1267 fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1268 let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1269 check_positive_money(money, "money").unwrap();
1270 }
1271
1272 #[rstest]
1273 #[should_panic]
1274 fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1275 let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1276 check_positive_money(money, "money").unwrap();
1277 }
1278}