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