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