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 parsing::min_increment_precision_from_str,
42};
43use rust_decimal::{Decimal, RoundingStrategy};
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, money::check_positive_money, 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>,
76 min_notional: Option<Money>,
77 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_positive_decimal(margin_init, "margin_init")?;
89 check_positive_decimal(margin_maint, "margin_maint")?;
90
91 if let Some(price_increment) = price_increment {
92 check_positive_price(price_increment, "price_increment")?;
93 check_equal_u8(
94 price_increment.precision,
95 price_precision,
96 "price_increment.precision",
97 "price_precision",
98 )?;
99 }
100
101 if let Some(lot) = lot_size {
102 check_positive_quantity(lot, "lot_size")?;
103 }
104
105 if let Some(quantity) = max_quantity {
106 check_positive_quantity(quantity, "max_quantity")?;
107 }
108
109 if let Some(quantity) = min_quantity {
110 check_positive_quantity(quantity, "max_quantity")?;
111 }
112
113 if let Some(notional) = max_notional {
114 check_positive_money(notional, "max_notional")?;
115 }
116
117 if let Some(notional) = min_notional {
118 check_positive_money(notional, "min_notional")?;
119 }
120
121 if let Some(max_price) = max_price {
122 check_positive_price(max_price, "max_price")?;
123 check_equal_u8(
124 max_price.precision,
125 price_precision,
126 "max_price.precision",
127 "price_precision",
128 )?;
129 }
130 if let Some(min_price) = min_price {
131 check_positive_price(min_price, "min_price")?;
132 check_equal_u8(
133 min_price.precision,
134 price_precision,
135 "min_price.precision",
136 "price_precision",
137 )?;
138 }
139
140 if let (Some(min), Some(max)) = (min_price, max_price) {
141 check_predicate_true(min.raw <= max.raw, "min_price exceeds max_price")?;
142 }
143
144 Ok(())
145}
146
147pub trait TickSchemeRule: Display {
148 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
149 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
150}
151
152#[derive(Clone, Copy, Debug)]
153pub struct FixedTickScheme {
154 tick: f64,
155}
156
157impl PartialEq for FixedTickScheme {
158 fn eq(&self, other: &Self) -> bool {
159 self.tick == other.tick
160 }
161}
162impl Eq for FixedTickScheme {}
163
164impl FixedTickScheme {
165 #[allow(clippy::missing_errors_doc)]
166 pub fn new(tick: f64) -> anyhow::Result<Self> {
167 check_predicate_true(tick > 0.0, "tick must be positive")?;
168 Ok(Self { tick })
169 }
170}
171
172impl TickSchemeRule for FixedTickScheme {
173 #[inline(always)]
174 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
175 let base = (value / self.tick).floor() * self.tick;
176 Some(Price::new(base - (n as f64) * self.tick, precision))
177 }
178
179 #[inline(always)]
180 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
181 let base = (value / self.tick).ceil() * self.tick;
182 Some(Price::new(base + (n as f64) * self.tick, precision))
183 }
184}
185
186impl Display for FixedTickScheme {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(f, "FIXED")
189 }
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193pub enum TickScheme {
194 Fixed(FixedTickScheme),
195 Crypto,
196}
197
198impl TickSchemeRule for TickScheme {
199 #[inline(always)]
200 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
201 match self {
202 Self::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
203 Self::Crypto => {
204 let increment: f64 = 0.01;
205 let base = (value / increment).floor() * increment;
206 Some(Price::new(base - (n as f64) * increment, precision))
207 }
208 }
209 }
210
211 #[inline(always)]
212 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
213 match self {
214 Self::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
215 Self::Crypto => {
216 let increment: f64 = 0.01;
217 let base = (value / increment).ceil() * increment;
218 Some(Price::new(base + (n as f64) * increment, precision))
219 }
220 }
221 }
222}
223
224impl Display for TickScheme {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 match self {
227 Self::Fixed(_) => write!(f, "FIXED"),
228 Self::Crypto => write!(f, "CRYPTO_0_01"),
229 }
230 }
231}
232
233impl FromStr for TickScheme {
234 type Err = anyhow::Error;
235
236 fn from_str(s: &str) -> Result<Self, Self::Err> {
237 match s.trim().to_ascii_uppercase().as_str() {
238 "FIXED" => Ok(Self::Fixed(FixedTickScheme::new(1.0)?)),
239 "CRYPTO_0_01" => Ok(Self::Crypto),
240 _ => anyhow::bail!("unknown tick scheme {s}"),
241 }
242 }
243}
244
245#[enum_dispatch]
246pub trait Instrument: 'static + Send {
247 fn tick_scheme(&self) -> Option<&dyn TickSchemeRule> {
248 None
249 }
250
251 fn into_any(self) -> InstrumentAny
252 where
253 Self: Sized,
254 InstrumentAny: From<Self>,
255 {
256 self.into()
257 }
258
259 fn id(&self) -> InstrumentId;
260 fn symbol(&self) -> Symbol {
261 self.id().symbol
262 }
263 fn venue(&self) -> Venue {
264 self.id().venue
265 }
266
267 fn raw_symbol(&self) -> Symbol;
268 fn asset_class(&self) -> AssetClass;
269 fn instrument_class(&self) -> InstrumentClass;
270
271 fn underlying(&self) -> Option<Ustr>;
272 fn base_currency(&self) -> Option<Currency>;
273 fn quote_currency(&self) -> Currency;
274 fn settlement_currency(&self) -> Currency;
275
276 fn cost_currency(&self) -> Currency {
280 if self.is_inverse() {
281 self.base_currency()
282 .expect("inverse instrument without base_currency")
283 } else {
284 self.quote_currency()
285 }
286 }
287
288 fn isin(&self) -> Option<Ustr>;
289 fn option_kind(&self) -> Option<OptionKind>;
290 fn exchange(&self) -> Option<Ustr>;
291 fn strike_price(&self) -> Option<Price>;
292
293 fn activation_ns(&self) -> Option<UnixNanos>;
294 fn expiration_ns(&self) -> Option<UnixNanos>;
295 fn has_expiration(&self) -> bool {
296 self.instrument_class().has_expiration()
297 }
298
299 fn is_inverse(&self) -> bool;
300 fn is_quanto(&self) -> bool {
301 self.base_currency()
302 .is_some_and(|currency| currency != self.settlement_currency())
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 min_increment_precision_from_str(&self.price_increment().to_string())
338 }
339
340 fn _min_size_increment_precision(&self) -> u8 {
341 min_increment_precision_from_str(&self.size_increment().to_string())
343 }
344
345 #[inline(always)]
349 fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
350 let dec_value = Decimal::from_str(&value.to_string())
351 .map_err(|_| anyhow::anyhow!("non-finite value passed to make_price"))?;
352 let precision = self._min_price_increment_precision() as u32;
353 let rounded_decimal =
354 dec_value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
355 Price::from_decimal_dp(rounded_decimal, 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 dec_value = Decimal::from_str(&value.to_string())
368 .map_err(|_| anyhow::anyhow!("non-finite value passed to make_qty"))?;
369 let precision = self._min_size_increment_precision() as u32;
370 let strategy = if round_down.unwrap_or(false) {
371 RoundingStrategy::ToZero
372 } else {
373 RoundingStrategy::MidpointNearestEven
374 };
375 let rounded = dec_value.round_dp_with_strategy(precision, strategy);
376 if dec_value > Decimal::ZERO && rounded.is_zero() {
377 anyhow::bail!("value rounded to zero for quantity");
378 }
379 Quantity::from_decimal_dp(rounded, self.size_precision())
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 let precision = self._min_size_increment_precision() as u32;
395 let value = (quantity.as_decimal() / last_price.as_decimal())
396 .round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
397 Quantity::from_decimal_dp(value, self.size_precision())
398 }
399
400 fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
401 self.try_calculate_base_quantity(quantity, last_price)
402 .unwrap()
403 }
404
405 #[inline(always)]
409 fn calculate_notional_value(
410 &self,
411 quantity: Quantity,
412 price: Price,
413 use_quote_for_inverse: Option<bool>,
414 ) -> Money {
415 let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
416 let (amount, currency) = if self.is_inverse() {
417 if use_quote_inverse {
418 (quantity.as_decimal(), self.quote_currency())
419 } else {
420 let amount =
421 quantity.as_decimal() * self.multiplier().as_decimal() / price.as_decimal();
422 let currency = self
423 .base_currency()
424 .expect("inverse instrument without base_currency");
425 (amount, currency)
426 }
427 } else if self.is_quanto() {
428 let amount =
429 quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
430 (amount, self.settlement_currency())
431 } else {
432 let amount =
433 quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
434 (amount, self.quote_currency())
435 };
436
437 Money::from_decimal(amount, currency).expect("Invalid notional value")
438 }
439
440 #[inline(always)]
441 fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
442 let price = if let Some(scheme) = self.tick_scheme() {
443 scheme.next_bid_price(value, n, self.price_precision())?
444 } else {
445 let value = Decimal::from_str(&value.to_string()).ok()?;
446 let increment = self.price_increment().as_decimal();
447 if increment.is_zero() {
448 return None;
449 }
450 let base = (value / increment).floor() * increment;
451 let result = base - Decimal::from(n) * increment;
452 Price::from_decimal_dp(result, self.price_precision()).ok()?
453 };
454
455 if self.min_price().is_some_and(|min| price < min)
456 || self.max_price().is_some_and(|max| price > max)
457 {
458 return None;
459 }
460
461 Some(price)
462 }
463
464 #[inline(always)]
465 fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
466 let price = if let Some(scheme) = self.tick_scheme() {
467 scheme.next_ask_price(value, n, self.price_precision())?
468 } else {
469 let value = Decimal::from_str(&value.to_string()).ok()?;
470 let increment = self.price_increment().as_decimal();
471 if increment.is_zero() {
472 return None;
473 }
474 let base = (value / increment).ceil() * increment;
475 let result = base + Decimal::from(n) * increment;
476 Price::from_decimal_dp(result, self.price_precision()).ok()?
477 };
478
479 if self.min_price().is_some_and(|min| price < min)
480 || self.max_price().is_some_and(|max| price > max)
481 {
482 return None;
483 }
484
485 Some(price)
486 }
487
488 #[inline]
489 fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
490 let mut prices = Vec::with_capacity(n);
491
492 for i in 0..n {
493 if let Some(price) = self.next_bid_price(value, i as i32) {
494 prices.push(price);
495 } else {
496 break;
497 }
498 }
499
500 prices
501 }
502
503 #[inline]
504 fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
505 let mut prices = Vec::with_capacity(n);
506
507 for i in 0..n {
508 if let Some(price) = self.next_ask_price(value, i as i32) {
509 prices.push(price);
510 } else {
511 break;
512 }
513 }
514
515 prices
516 }
517}
518
519impl Display for CurrencyPair {
520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521 write!(
522 f,
523 "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
524price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
525 stringify!(CurrencyPair),
526 self.id,
527 self.tick_scheme()
528 .map_or_else(|| "None".into(), |s| s.to_string()),
529 self.price_precision(),
530 self.size_precision(),
531 self.price_increment(),
532 self.size_increment(),
533 self.multiplier(),
534 self.margin_init(),
535 self.margin_maint(),
536 )
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use std::str::FromStr;
543
544 use proptest::prelude::*;
545 use rstest::rstest;
546 use rust_decimal::{Decimal, prelude::*};
547
548 use super::*;
549 use crate::{instruments::stubs::*, types::Money};
550
551 pub fn default_price_increment(precision: u8) -> Price {
552 let step = 10f64.powi(-(precision as i32));
553 Price::new(step, precision)
554 }
555
556 #[rstest]
557 fn default_increment_precision() {
558 let inc = default_price_increment(2);
559 assert_eq!(inc, Price::new(0.01, 2));
560 }
561
562 #[rstest]
563 #[case(Price::new(0.5, 1), 1)] #[case(Price::new(0.50, 2), 1)] #[case(Price::new(0.500, 3), 1)] #[case(Price::new(0.01, 2), 2)] #[case(Price::new(0.010, 3), 2)] #[case(Price::new(0.25, 2), 2)] #[case(Price::new(1.0, 1), 1)] #[case(Price::new(1.00, 2), 2)] #[case(Price::new(100.0, 0), 0)] #[case(Price::new(0.001, 3), 3)] fn test_min_increment_precision(#[case] price: Price, #[case] expected: u8) {
574 assert_eq!(
575 nautilus_core::parsing::min_increment_precision_from_str(&price.to_string()),
576 expected
577 );
578 }
579
580 #[rstest]
581 #[case(1.5, "1.500000")]
582 #[case(2.5, "2.500000")]
583 #[case(1.2345678, "1.234568")]
584 #[case(0.000123, "0.000123")]
585 #[case(99999.999999, "99999.999999")]
586 fn make_qty_rounding(
587 currency_pair_btcusdt: CurrencyPair,
588 #[case] input: f64,
589 #[case] expected: &str,
590 ) {
591 assert_eq!(
592 currency_pair_btcusdt.make_qty(input, None).to_string(),
593 expected
594 );
595 }
596
597 #[rstest]
598 #[case(1.2345678, "1.234567")]
599 #[case(1.9999999, "1.999999")]
600 #[case(0.00012345, "0.000123")]
601 #[case(10.9999999, "10.999999")]
602 fn make_qty_round_down(
603 currency_pair_btcusdt: CurrencyPair,
604 #[case] input: f64,
605 #[case] expected: &str,
606 ) {
607 assert_eq!(
608 currency_pair_btcusdt
609 .make_qty(input, Some(true))
610 .to_string(),
611 expected
612 );
613 }
614
615 #[rstest]
616 #[case(1.2345678, "1.23457")]
617 #[case(2.3456781, "2.34568")]
618 #[case(0.00001, "0.00001")]
619 fn make_qty_precision(
620 currency_pair_ethusdt: CurrencyPair,
621 #[case] input: f64,
622 #[case] expected: &str,
623 ) {
624 assert_eq!(
625 currency_pair_ethusdt.make_qty(input, None).to_string(),
626 expected
627 );
628 }
629
630 #[rstest]
631 #[case(1.2345675, "1.234568")]
632 #[case(1.2345665, "1.234566")]
633 fn make_qty_half_even(
634 currency_pair_btcusdt: CurrencyPair,
635 #[case] input: f64,
636 #[case] expected: &str,
637 ) {
638 assert_eq!(
639 currency_pair_btcusdt.make_qty(input, None).to_string(),
640 expected
641 );
642 }
643
644 #[rstest]
645 #[should_panic]
646 fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
647 currency_pair_btcusdt.make_qty(1e-12, None);
648 }
649
650 #[rstest]
651 fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
652 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
653 let price = currency_pair_btcusdt.make_price(10_000.0);
654 let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
655 let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
656 assert_eq!(notional, expected);
657 }
658
659 #[rstest]
660 fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
661 let start = 10_000.123_4;
662 let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
663 let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
664 assert!(bid_1 < bid_0);
665 let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
666 assert_eq!(asks.len(), 3);
667 assert!(asks[0] > bid_0);
668 }
669
670 #[rstest]
671 #[should_panic]
672 fn validate_negative_margin_init() {
673 let size_increment = Quantity::new(0.01, 2);
674 let multiplier = Quantity::new(1.0, 0);
675
676 validate_instrument_common(
677 2,
678 2, size_increment, multiplier, dec!(-0.01), dec!(0.01), None, None, None, None, None, None, None, None, )
692 .unwrap();
693 }
694
695 #[rstest]
696 #[should_panic]
697 fn validate_negative_margin_maint() {
698 let size_increment = Quantity::new(0.01, 2);
699 let multiplier = Quantity::new(1.0, 0);
700
701 validate_instrument_common(
702 2,
703 2, size_increment, multiplier, dec!(0.01), dec!(-0.01), None, None, None, None, None, None, None, None, )
717 .unwrap();
718 }
719
720 #[rstest]
721 #[should_panic]
722 fn validate_negative_max_qty() {
723 let quantity = Quantity::new(0.0, 0);
724 validate_instrument_common(
725 2,
726 2,
727 Quantity::new(0.01, 2),
728 Quantity::new(1.0, 0),
729 dec!(0),
730 dec!(0),
731 None,
732 None,
733 Some(quantity),
734 None,
735 None,
736 None,
737 None,
738 None,
739 )
740 .unwrap();
741 }
742
743 #[rstest]
744 fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
745 let price = currency_pair_ethusdt.make_price(-123.456_789);
746 assert!(price.as_f64() < 0.0);
747 }
748
749 #[rstest]
750 fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
751 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
752 let price = currency_pair_btcusdt.make_price(10_000.0);
753 let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
754 assert_eq!(base.to_string(), "0.000200");
755 }
756
757 #[rstest]
758 fn fixed_tick_scheme_prices() {
759 let scheme = FixedTickScheme::new(0.5).unwrap();
760 let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
761 let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
762 assert!(bid < ask);
763 }
764
765 #[rstest]
766 #[should_panic]
767 fn fixed_tick_negative() {
768 FixedTickScheme::new(-0.01).unwrap();
769 }
770
771 #[rstest]
772 fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
773 let start = 10_000.0;
774 let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
775 assert_eq!(bids.len(), 5);
776 for i in 1..bids.len() {
777 assert!(bids[i] < bids[i - 1]);
778 }
779 }
780
781 #[rstest]
782 fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
783 let start = 10_000.0;
784 let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
785 assert_eq!(asks.len(), 5);
786 for i in 1..asks.len() {
787 assert!(asks[i] > asks[i - 1]);
788 }
789 }
790
791 #[rstest]
792 fn fixed_tick_boundary() {
793 let scheme = FixedTickScheme::new(0.5).unwrap();
794 let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
795 assert_eq!(price, Price::new(10.5, 2));
796 }
797
798 #[rstest]
799 #[should_panic]
800 fn validate_price_increment_precision_mismatch() {
801 let size_increment = Quantity::new(0.01, 2);
802 let multiplier = Quantity::new(1.0, 0);
803 let price_increment = Price::new(0.001, 3);
804 validate_instrument_common(
805 2,
806 2,
807 size_increment,
808 multiplier,
809 dec!(0),
810 dec!(0),
811 Some(price_increment),
812 None,
813 None,
814 None,
815 None,
816 None,
817 None,
818 None,
819 )
820 .unwrap();
821 }
822
823 #[rstest]
824 #[should_panic]
825 fn validate_min_price_exceeds_max_price() {
826 let size_increment = Quantity::new(0.01, 2);
827 let multiplier = Quantity::new(1.0, 0);
828 let min_price = Price::new(10.0, 2);
829 let max_price = Price::new(5.0, 2);
830 validate_instrument_common(
831 2,
832 2,
833 size_increment,
834 multiplier,
835 dec!(0),
836 dec!(0),
837 None,
838 None,
839 None,
840 None,
841 None,
842 None,
843 Some(max_price),
844 Some(min_price),
845 )
846 .unwrap();
847 }
848
849 #[rstest]
850 fn validate_instrument_common_ok() {
851 let res = validate_instrument_common(
852 2,
853 4,
854 Quantity::new(0.0001, 4),
855 Quantity::new(1.0, 0),
856 dec!(0.02),
857 dec!(0.01),
858 Some(Price::new(0.01, 2)),
859 None,
860 None,
861 None,
862 None,
863 None,
864 None,
865 None,
866 );
867 assert!(matches!(res, Ok(())));
868 }
869
870 #[rstest]
871 #[should_panic]
872 fn validate_multiple_errors() {
873 validate_instrument_common(
874 2,
875 2,
876 Quantity::new(-0.01, 2),
877 Quantity::new(0.0, 0),
878 dec!(0),
879 dec!(0),
880 None,
881 None,
882 None,
883 None,
884 None,
885 None,
886 None,
887 None,
888 )
889 .unwrap();
890 }
891
892 #[rstest]
893 #[case(1.234_999_9, false, "1.235000")]
894 #[case(1.234_999_9, true, "1.234999")]
895 fn make_qty_boundary(
896 currency_pair_btcusdt: CurrencyPair,
897 #[case] input: f64,
898 #[case] round_down: bool,
899 #[case] expected: &str,
900 ) {
901 let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
902 assert_eq!(quantity.to_string(), expected);
903 }
904
905 #[rstest]
906 fn fixed_tick_multiple_steps() {
907 let scheme = FixedTickScheme::new(1.0).unwrap();
908 let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
909 let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
910 assert_eq!(bid, Price::new(8.0, 1));
911 assert_eq!(ask, Price::new(13.0, 1));
912 }
913
914 #[rstest]
915 #[case(1.234_999, 1.23)]
916 #[case(1.235, 1.24)]
917 #[case(1.235_001, 1.24)]
918 fn make_price_rounding_parity(
919 currency_pair_btcusdt: CurrencyPair,
920 #[case] input: f64,
921 #[case] expected: f64,
922 ) {
923 let price = currency_pair_btcusdt.make_price(input);
924 assert!((price.as_f64() - expected).abs() < 1e-9);
925 }
926
927 #[rstest]
928 fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
929 let rounding_precision = std::cmp::min(
930 currency_pair_btcusdt.price_precision(),
931 currency_pair_btcusdt._min_price_increment_precision(),
932 );
933 let step = 10f64.powi(-(rounding_precision as i32));
934 let base_even_multiple = 42.0;
935 let base_value = step * base_even_multiple;
936 let delta = step / 2000.0;
937 let value_below = base_value + 0.5 * step - delta;
938 let value_exact = base_value + 0.5 * step;
939 let value_above = base_value + 0.5 * step + delta;
940 let price_below = currency_pair_btcusdt.make_price(value_below);
941 let price_exact = currency_pair_btcusdt.make_price(value_exact);
942 let price_above = currency_pair_btcusdt.make_price(value_above);
943 assert_eq!(price_below, price_exact);
944 assert_ne!(price_exact, price_above);
945 }
946
947 #[rstest]
948 fn tick_scheme_round_trip() {
949 let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
950 assert_eq!(scheme.to_string(), "CRYPTO_0_01");
951 }
952
953 #[rstest]
954 fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
955 assert!(ethbtc_quanto.is_quanto());
956 }
957
958 #[rstest]
959 fn notional_quanto(ethbtc_quanto: CryptoFuture) {
960 let quantity = ethbtc_quanto.make_qty(5.0, None);
961 let price = ethbtc_quanto.make_price(0.036);
962 let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
963 let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
964 assert_eq!(notional, expected);
965 }
966
967 #[rstest]
968 fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
969 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
970 let price = xbtusd_inverse_perp.make_price(50_000.0);
971 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
972 let expected = Money::new(
973 100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
974 xbtusd_inverse_perp.base_currency().unwrap(),
975 );
976 assert_eq!(notional, expected);
977 }
978
979 #[rstest]
980 fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
981 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
982 let price = xbtusd_inverse_perp.make_price(50_000.0);
983 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
984 let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
985 assert_eq!(notional, expected);
986 }
987
988 #[rstest]
989 #[should_panic]
990 fn validate_non_positive_max_price() {
991 let size_increment = Quantity::new(0.01, 2);
992 let multiplier = Quantity::new(1.0, 0);
993 let max_price = Price::new(0.0, 2);
994 validate_instrument_common(
995 2,
996 2,
997 size_increment,
998 multiplier,
999 dec!(0),
1000 dec!(0),
1001 None,
1002 None,
1003 None,
1004 None,
1005 None,
1006 None,
1007 Some(max_price),
1008 None,
1009 )
1010 .unwrap();
1011 }
1012
1013 #[rstest]
1014 #[should_panic]
1015 fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1016 let size_increment = Quantity::new(0.01, 2);
1017 let multiplier = Quantity::new(1.0, 0);
1018 let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1019 validate_instrument_common(
1020 2,
1021 2,
1022 size_increment,
1023 multiplier,
1024 dec!(0),
1025 dec!(0),
1026 None,
1027 None,
1028 None,
1029 None,
1030 Some(max_notional),
1031 None,
1032 None,
1033 None,
1034 )
1035 .unwrap();
1036 }
1037
1038 #[rstest]
1039 #[should_panic]
1040 fn validate_price_increment_min_price_precision_mismatch() {
1041 let size_increment = Quantity::new(0.01, 2);
1042 let multiplier = Quantity::new(1.0, 0);
1043 let price_increment = Price::new(0.01, 2);
1044 let min_price = Price::new(1.0, 3);
1045 validate_instrument_common(
1046 2,
1047 2,
1048 size_increment,
1049 multiplier,
1050 dec!(0),
1051 dec!(0),
1052 Some(price_increment),
1053 None,
1054 None,
1055 None,
1056 None,
1057 None,
1058 None,
1059 Some(min_price),
1060 )
1061 .unwrap();
1062 }
1063
1064 #[rstest]
1065 #[should_panic]
1066 fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1067 let size_increment = Quantity::new(0.01, 2);
1068 let multiplier = Quantity::new(1.0, 0);
1069 let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1070 let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1071 validate_instrument_common(
1072 2,
1073 2,
1074 size_increment,
1075 multiplier,
1076 dec!(0),
1077 dec!(0),
1078 None,
1079 None,
1080 None,
1081 None,
1082 Some(max_notional),
1083 Some(min_notional),
1084 None,
1085 None,
1086 )
1087 .unwrap();
1088 }
1089
1090 #[rstest]
1091 #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1092 #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1093 #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1094 #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1095 #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1096 #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1097 #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1098 #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1099 #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1100 fn base_qty_rounding(
1101 currency_pair_btcusdt: CurrencyPair,
1102 #[case] q: Decimal,
1103 #[case] px: Decimal,
1104 #[case] expected: f64,
1105 ) {
1106 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1107 let price = Price::new(px.to_f64().unwrap(), 8);
1108 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1109 assert!((base.as_f64() - expected).abs() < 1e-9);
1110 }
1111
1112 proptest! {
1113 #[rstest]
1114 fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1115 let instrument = currency_pair_btcusdt();
1116 let price = instrument.make_price(input);
1117 prop_assert!(price.as_f64().is_finite());
1118 let quantity = instrument.make_qty(input, None);
1119 prop_assert!(quantity.as_f64().is_finite());
1120 }
1121 }
1122
1123 #[rstest]
1124 fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1125 if let Some(max_price) = currency_pair_btcusdt.max_price() {
1126 assert!(
1127 currency_pair_btcusdt
1128 .next_ask_price(max_price.as_f64(), 1)
1129 .is_none()
1130 );
1131 }
1132 }
1133
1134 #[rstest]
1135 fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1136 if let Some(max_price) = currency_pair_ethusdt.max_price() {
1137 assert!(
1138 currency_pair_ethusdt
1139 .next_ask_price(max_price.as_f64(), 1)
1140 .is_none()
1141 );
1142 }
1143 }
1144
1145 #[rstest]
1146 fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1147 if let Some(min_price) = currency_pair_btcusdt.min_price() {
1148 assert!(
1149 currency_pair_btcusdt
1150 .next_bid_price(min_price.as_f64(), 1)
1151 .is_none()
1152 );
1153 }
1154 }
1155
1156 #[rstest]
1157 fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1158 if let Some(min_price) = currency_pair_ethusdt.min_price() {
1159 assert!(
1160 currency_pair_ethusdt
1161 .next_bid_price(min_price.as_f64(), 1)
1162 .is_none()
1163 );
1164 }
1165 }
1166
1167 #[rstest]
1168 fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1169 if let Some(max_price) = ethbtc_quanto.max_price() {
1170 assert!(
1171 ethbtc_quanto
1172 .next_ask_price(max_price.as_f64(), 1)
1173 .is_none()
1174 );
1175 }
1176 }
1177
1178 #[rstest]
1179 #[case(0.999_999, false)]
1180 #[case(0.999_999, true)]
1181 #[case(1.000_000_1, false)]
1182 #[case(1.000_000_1, true)]
1183 #[case(1.234_5, false)]
1184 #[case(1.234_5, true)]
1185 #[case(2.345_5, false)]
1186 #[case(2.345_5, true)]
1187 #[case(0.000_999_999, false)]
1188 #[case(0.000_999_999, true)]
1189 fn quantity_rounding_grid(
1190 currency_pair_btcusdt: CurrencyPair,
1191 #[case] input: f64,
1192 #[case] round_down: bool,
1193 ) {
1194 let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1195 assert!(qty.as_f64().is_finite());
1196 }
1197
1198 #[rstest]
1199 fn pyo3_failure_tick_scheme_unknown() {
1200 assert!(TickScheme::from_str("UNKNOWN").is_err());
1201 }
1202
1203 #[rstest]
1204 fn pyo3_failure_fixed_tick_zero() {
1205 assert!(FixedTickScheme::new(0.0).is_err());
1206 }
1207
1208 #[rstest]
1209 fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1210 let size_increment = Quantity::new(0.01, 2);
1211 let multiplier = Quantity::new(1.0, 0);
1212 let price_increment = Price::new(0.01, 2);
1213 let max_price = Price::new(1.0, 3);
1214 let res = validate_instrument_common(
1215 2,
1216 2,
1217 size_increment,
1218 multiplier,
1219 dec!(0),
1220 dec!(0),
1221 Some(price_increment),
1222 None,
1223 None,
1224 None,
1225 None,
1226 None,
1227 Some(max_price),
1228 None,
1229 );
1230 assert!(res.is_err());
1231 }
1232
1233 #[rstest]
1234 #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1235 #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1236 #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1237 #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1238 #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1239 #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1240 #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1241 #[case::dp16(
1242 Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1243 Decimal::new(2, 0),
1244 500.0
1245 )]
1246 #[case::dp17(
1247 Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1248 Decimal::new(2, 0),
1249 500.0
1250 )]
1251 fn base_qty_rounding_high_dp(
1252 currency_pair_btcusdt: CurrencyPair,
1253 #[case] q: Decimal,
1254 #[case] px: Decimal,
1255 #[case] expected: f64,
1256 ) {
1257 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1258 let price = Price::new(px.to_f64().unwrap(), 8);
1259 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1260 assert!((base.as_f64() - expected).abs() < 1e-9);
1261 }
1262
1263 #[rstest]
1264 fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1265 let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1266 assert!(check_positive_money(money, "money").is_ok());
1267 }
1268
1269 #[rstest]
1270 #[should_panic]
1271 fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1272 let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1273 check_positive_money(money, "money").unwrap();
1274 }
1275
1276 #[rstest]
1277 #[should_panic]
1278 fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1279 let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1280 check_positive_money(money, "money").unwrap();
1281 }
1282
1283 #[rstest]
1284 fn make_price_with_trailing_zeros_in_increment() {
1285 let instrument = CurrencyPair::new(
1288 InstrumentId::from("TEST.VENUE"),
1289 Symbol::from("TEST"),
1290 Currency::from("BTC"),
1291 Currency::from("USD"),
1292 2, 2, Price::new(0.50, 2), Quantity::from("0.01"),
1296 None,
1297 None,
1298 None,
1299 None,
1300 None,
1301 None,
1302 None,
1303 None,
1304 None,
1305 None,
1306 None,
1307 None,
1308 UnixNanos::default(),
1309 UnixNanos::default(),
1310 );
1311
1312 assert_eq!(instrument._min_price_increment_precision(), 1);
1314
1315 let price = instrument.make_price(1.234);
1318 assert_eq!(price.as_f64(), 1.2);
1319
1320 let price = instrument.make_price(1.25);
1322 assert_eq!(price.as_f64(), 1.2);
1323
1324 let price = instrument.make_price(1.35);
1326 assert_eq!(price.as_f64(), 1.4);
1327
1328 assert_eq!(price.precision, 2);
1330 }
1331
1332 #[rstest]
1333 fn make_qty_with_trailing_zeros_in_increment() {
1334 let instrument = CurrencyPair::new(
1336 InstrumentId::from("TEST.VENUE"),
1337 Symbol::from("TEST"),
1338 Currency::from("BTC"),
1339 Currency::from("USD"),
1340 2, 2, Price::new(0.01, 2),
1343 Quantity::new(0.50, 2), None,
1345 None,
1346 None,
1347 None,
1348 None,
1349 None,
1350 None,
1351 None,
1352 None,
1353 None,
1354 None,
1355 None,
1356 UnixNanos::default(),
1357 UnixNanos::default(),
1358 );
1359
1360 assert_eq!(instrument._min_size_increment_precision(), 1);
1362
1363 let qty = instrument.make_qty(1.234, None);
1366 assert_eq!(qty.as_f64(), 1.2);
1367
1368 let qty = instrument.make_qty(1.25, None);
1370 assert_eq!(qty.as_f64(), 1.2);
1371
1372 let qty = instrument.make_qty(1.35, None);
1374 assert_eq!(qty.as_f64(), 1.4);
1375
1376 assert_eq!(qty.precision, 2);
1378
1379 let qty = instrument.make_qty(1.99, Some(true));
1381 assert_eq!(qty.as_f64(), 1.9);
1382 }
1383
1384 #[rstest]
1385 #[case(InstrumentClass::Future, true)]
1386 #[case(InstrumentClass::FuturesSpread, true)]
1387 #[case(InstrumentClass::Option, true)]
1388 #[case(InstrumentClass::OptionSpread, true)]
1389 #[case(InstrumentClass::Spot, false)]
1390 #[case(InstrumentClass::Swap, false)]
1391 #[case(InstrumentClass::Forward, false)]
1392 #[case(InstrumentClass::Cfd, false)]
1393 #[case(InstrumentClass::Bond, false)]
1394 #[case(InstrumentClass::Warrant, false)]
1395 #[case(InstrumentClass::SportsBetting, false)]
1396 #[case(InstrumentClass::BinaryOption, false)]
1397 fn test_instrument_class_has_expiration(
1398 #[case] instrument_class: InstrumentClass,
1399 #[case] expected: bool,
1400 ) {
1401 assert_eq!(instrument_class.has_expiration(), expected);
1402 }
1403}