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