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