1pub mod any;
19pub mod betting;
20pub mod binary_option;
21pub mod cfd;
22pub mod commodity;
23pub mod crypto_future;
24pub mod crypto_futures_spread;
25pub mod crypto_option;
26pub mod crypto_option_spread;
27pub mod crypto_perpetual;
28pub mod currency_pair;
29pub mod equity;
30pub mod futures_contract;
31pub mod futures_spread;
32pub mod index_instrument;
33pub mod option_contract;
34pub mod option_spread;
35pub mod perpetual_contract;
36pub mod synthetic;
37pub mod tick_scheme;
38pub mod tokenized_asset;
39
40#[cfg(any(test, feature = "stubs"))]
41pub mod stubs;
42
43use std::{fmt::Display, str::FromStr};
44
45use enum_dispatch::enum_dispatch;
46use nautilus_core::{
47 UnixNanos,
48 correctness::{
49 CorrectnessError, CorrectnessResult, check_equal_u8, check_positive_decimal,
50 check_predicate_true,
51 },
52 string::parsing::min_increment_precision_from_str,
53};
54use rust_decimal::{Decimal, RoundingStrategy};
55use rust_decimal_macros::dec;
56use ustr::Ustr;
57
58pub use crate::instruments::{
59 any::InstrumentAny,
60 betting::BettingInstrument,
61 binary_option::BinaryOption,
62 cfd::Cfd,
63 commodity::Commodity,
64 crypto_future::CryptoFuture,
65 crypto_futures_spread::CryptoFuturesSpread,
66 crypto_option::CryptoOption,
67 crypto_option_spread::CryptoOptionSpread,
68 crypto_perpetual::CryptoPerpetual,
69 currency_pair::CurrencyPair,
70 equity::Equity,
71 futures_contract::FuturesContract,
72 futures_spread::FuturesSpread,
73 index_instrument::IndexInstrument,
74 option_contract::OptionContract,
75 option_spread::OptionSpread,
76 perpetual_contract::PerpetualContract,
77 synthetic::{SyntheticInstrument, SyntheticInstrumentError},
78 tick_scheme::{
79 FixedTickScheme, TickScheme, TickSchemeError, TickSchemeRule, TieredTickScheme,
80 tick_scheme_rule_from_name,
81 },
82 tokenized_asset::TokenizedAsset,
83};
84use crate::{
85 enums::{AssetClass, InstrumentClass, OptionKind},
86 identifiers::{InstrumentId, Symbol, Venue},
87 types::{
88 Currency, ERROR_PRICE, Money, PRICE_ERROR, Price, Quantity,
89 fixed::{FIXED_PRECISION, raw_scales_match},
90 money::check_positive_money,
91 price::{PriceRaw, check_positive_price},
92 quantity::{QuantityRaw, check_positive_quantity},
93 },
94};
95
96#[expect(clippy::missing_errors_doc, clippy::too_many_arguments)]
97pub fn validate_instrument_common(
98 price_precision: u8,
99 size_precision: u8,
100 size_increment: Quantity,
101 multiplier: Quantity,
102 margin_init: Decimal,
103 margin_maint: Decimal,
104 price_increment: Option<Price>,
105 lot_size: Option<Quantity>,
106 max_quantity: Option<Quantity>,
107 min_quantity: Option<Quantity>,
108 max_notional: Option<Money>,
109 min_notional: Option<Money>,
110 max_price: Option<Price>,
111 min_price: Option<Price>,
112) -> CorrectnessResult<()> {
113 check_positive_quantity(size_increment, "size_increment")?;
114 check_equal_u8(
115 size_increment.precision,
116 size_precision,
117 "size_increment.precision",
118 "size_precision",
119 )?;
120 check_positive_quantity(multiplier, "multiplier")?;
121 check_positive_decimal(margin_init, "margin_init")?;
122 check_positive_decimal(margin_maint, "margin_maint")?;
123
124 if let Some(price_increment) = price_increment {
125 check_positive_price(price_increment, "price_increment")?;
126 check_equal_u8(
127 price_increment.precision,
128 price_precision,
129 "price_increment.precision",
130 "price_precision",
131 )?;
132 }
133
134 if let Some(lot) = lot_size {
135 check_positive_quantity(lot, "lot_size")?;
136 }
137
138 if let Some(quantity) = max_quantity {
139 check_positive_quantity(quantity, "max_quantity")?;
140 }
141
142 if let Some(quantity) = min_quantity {
143 check_positive_quantity(quantity, "min_quantity")?;
144 }
145
146 if let Some(notional) = max_notional {
147 check_positive_money(notional, "max_notional")?;
148 }
149
150 if let Some(notional) = min_notional {
151 check_positive_money(notional, "min_notional")?;
152 }
153
154 if let Some(max_price) = max_price {
155 check_positive_price(max_price, "max_price")?;
156 check_equal_u8(
157 max_price.precision,
158 price_precision,
159 "max_price.precision",
160 "price_precision",
161 )?;
162 }
163
164 if let Some(min_price) = min_price {
165 check_positive_price(min_price, "min_price")?;
166 check_equal_u8(
167 min_price.precision,
168 price_precision,
169 "min_price.precision",
170 "price_precision",
171 )?;
172 }
173
174 if let (Some(min), Some(max)) = (min_price, max_price) {
175 check_predicate_true(min.raw <= max.raw, "min_price exceeds max_price")?;
176 }
177
178 Ok(())
179}
180
181fn currencies_equivalent_for_quanto(left: Currency, right: Currency) -> bool {
182 if left == right {
183 return true;
184 }
185
186 is_usd_equivalent_currency(left) && is_usd_equivalent_currency(right)
187}
188
189fn is_usd_equivalent_currency(currency: Currency) -> bool {
190 matches!(
191 currency.code.as_str(),
192 "BUSD" | "FDUSD" | "pUSD" | "TUSD" | "USD" | "USDC" | "USDC.e" | "USDP" | "USDT"
193 )
194}
195
196#[enum_dispatch]
197pub trait Instrument: 'static + Send {
198 fn tick_scheme(&self) -> Option<Ustr> {
199 None
200 }
201
202 fn tick_scheme_rule(&self) -> Option<&dyn TickSchemeRule> {
203 self.tick_scheme()
204 .and_then(|scheme| tick_scheme_rule_from_name(scheme.as_str()))
205 }
206
207 fn into_any(self) -> InstrumentAny
208 where
209 Self: Sized,
210 InstrumentAny: From<Self>,
211 {
212 self.into()
213 }
214
215 fn id(&self) -> InstrumentId;
216 fn symbol(&self) -> Symbol {
217 self.id().symbol
218 }
219 fn venue(&self) -> Venue {
220 self.id().venue
221 }
222
223 fn raw_symbol(&self) -> Symbol;
224 fn asset_class(&self) -> AssetClass;
225 fn instrument_class(&self) -> InstrumentClass;
226
227 fn underlying(&self) -> Option<Ustr>;
228 fn base_currency(&self) -> Option<Currency>;
229 fn quote_currency(&self) -> Currency;
230 fn settlement_currency(&self) -> Currency;
231
232 fn cost_currency(&self) -> Currency {
236 if self.is_inverse() {
237 self.base_currency()
238 .expect("inverse instrument without base_currency")
239 } else if self.is_quanto() {
240 self.settlement_currency()
241 } else {
242 self.quote_currency()
243 }
244 }
245
246 fn isin(&self) -> Option<Ustr>;
247 fn option_kind(&self) -> Option<OptionKind>;
248 fn exchange(&self) -> Option<Ustr>;
249 fn strike_price(&self) -> Option<Price>;
250 fn strategy_type(&self) -> Option<Ustr> {
251 None
252 }
253
254 fn activation_ns(&self) -> Option<UnixNanos>;
255 fn expiration_ns(&self) -> Option<UnixNanos>;
256 fn has_expiration(&self) -> bool {
257 self.instrument_class().has_expiration()
258 }
259
260 fn allows_negative_price(&self) -> bool {
261 self.instrument_class().allows_negative_price()
262 }
263
264 fn is_inverse(&self) -> bool;
265 fn is_quanto(&self) -> bool {
266 self.base_currency().is_some_and(|base_currency| {
267 self.settlement_currency() != base_currency
268 && !currencies_equivalent_for_quanto(
269 self.settlement_currency(),
270 self.quote_currency(),
271 )
272 })
273 }
274
275 fn price_precision(&self) -> u8;
276 fn size_precision(&self) -> u8;
277 fn price_increment(&self) -> Price;
278 fn size_increment(&self) -> Quantity;
279
280 fn multiplier(&self) -> Quantity;
281 fn lot_size(&self) -> Option<Quantity>;
282 fn max_quantity(&self) -> Option<Quantity>;
283 fn min_quantity(&self) -> Option<Quantity>;
284 fn max_notional(&self) -> Option<Money>;
285 fn min_notional(&self) -> Option<Money>;
286 fn max_price(&self) -> Option<Price>;
287 fn min_price(&self) -> Option<Price>;
288
289 fn margin_init(&self) -> Decimal {
290 dec!(0)
291 }
292 fn margin_maint(&self) -> Decimal {
293 dec!(0)
294 }
295 fn maker_fee(&self) -> Decimal {
296 dec!(0)
297 }
298 fn taker_fee(&self) -> Decimal {
299 dec!(0)
300 }
301
302 fn ts_event(&self) -> UnixNanos;
303 fn ts_init(&self) -> UnixNanos;
304
305 fn min_price_increment_precision(&self) -> u8 {
306 min_increment_precision_from_str(&self.price_increment().to_string())
308 }
309
310 fn min_size_increment_precision(&self) -> u8 {
311 min_increment_precision_from_str(&self.size_increment().to_string())
313 }
314
315 #[inline(always)]
320 fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
321 let dec_value = Decimal::from_str(&value.to_string())
322 .map_err(|_| anyhow::anyhow!("invalid `value` for make_price, was {value}"))?;
323 let precision = u32::from(self.min_price_increment_precision());
324 let rounded_decimal =
325 dec_value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
326 Price::from_decimal_dp(rounded_decimal, self.price_precision()).map_err(Into::into)
327 }
328
329 fn make_price(&self, value: f64) -> Price {
333 self.try_make_price(value).unwrap()
334 }
335
336 #[inline(always)]
342 fn try_normalize_price(&self, price: Price) -> CorrectnessResult<Price> {
343 if price == ERROR_PRICE {
344 return Err(CorrectnessError::InvalidValue {
345 param: "price".to_string(),
346 value: "ERROR_PRICE".to_string(),
347 type_name: "`Price`",
348 });
349 }
350
351 if price.raw == PRICE_ERROR {
352 return Err(CorrectnessError::InvalidValue {
353 param: "price".to_string(),
354 value: "PRICE_ERROR".to_string(),
355 type_name: "`Price`",
356 });
357 }
358
359 if price.is_undefined() {
360 return Err(CorrectnessError::InvalidValue {
361 param: "price".to_string(),
362 value: "PRICE_UNDEF".to_string(),
363 type_name: "`Price`",
364 });
365 }
366
367 let precision = self.price_precision();
368 let increment = self.price_increment();
369
370 if !raw_scales_match(price.precision, precision) {
371 return Err(CorrectnessError::PredicateViolation {
372 message: format!(
373 "`price` raw scale does not match instrument price precision, price precision was {}, instrument price precision was {precision}",
374 price.precision
375 ),
376 });
377 }
378
379 if !raw_scales_match(price.precision, increment.precision) {
380 return Err(CorrectnessError::PredicateViolation {
381 message: format!(
382 "`price` raw scale does not match price increment precision, price precision was {}, price increment precision was {}",
383 price.precision, increment.precision
384 ),
385 });
386 }
387
388 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
389 let scale = PriceRaw::pow(10, u32::from(precision_diff));
390
391 if price.raw % scale != 0 {
392 return Err(CorrectnessError::PredicateViolation {
393 message: format!(
394 "`price` requires rounding to instrument price precision {precision}, was {price}"
395 ),
396 });
397 }
398
399 let increment_raw = increment.raw.abs();
400 if increment_raw != 0 && price.raw % increment_raw != 0 {
401 return Err(CorrectnessError::PredicateViolation {
402 message: format!(
403 "`price` is not aligned to price increment {increment}, was {price}"
404 ),
405 });
406 }
407
408 Price::from_raw_checked(price.raw, precision)
409 }
410
411 #[inline(always)]
416 fn try_make_qty(&self, value: f64, round_down: Option<bool>) -> anyhow::Result<Quantity> {
417 let dec_value = Decimal::from_str(&value.to_string())
418 .map_err(|_| anyhow::anyhow!("invalid `value` for make_qty, was {value}"))?;
419 let precision = u32::from(self.min_size_increment_precision());
420
421 let strategy = if round_down.unwrap_or(false) {
422 RoundingStrategy::ToZero
423 } else {
424 RoundingStrategy::MidpointNearestEven
425 };
426
427 let rounded = dec_value.round_dp_with_strategy(precision, strategy);
428 if dec_value > Decimal::ZERO && rounded.is_zero() {
429 anyhow::bail!("value rounded to zero for quantity");
430 }
431
432 Quantity::from_decimal_dp(rounded, self.size_precision()).map_err(Into::into)
433 }
434
435 fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
439 self.try_make_qty(value, round_down).unwrap()
440 }
441
442 #[inline(always)]
448 fn try_normalize_qty(&self, quantity: Quantity) -> CorrectnessResult<Quantity> {
449 if quantity.is_undefined() {
450 return Err(CorrectnessError::InvalidValue {
451 param: "quantity".to_string(),
452 value: "QUANTITY_UNDEF".to_string(),
453 type_name: "`Quantity`",
454 });
455 }
456
457 let precision = self.size_precision();
458 let increment = self.size_increment();
459
460 if !raw_scales_match(quantity.precision, precision) {
461 return Err(CorrectnessError::PredicateViolation {
462 message: format!(
463 "`quantity` raw scale does not match instrument size precision, quantity precision was {}, instrument size precision was {precision}",
464 quantity.precision
465 ),
466 });
467 }
468
469 if !raw_scales_match(quantity.precision, increment.precision) {
470 return Err(CorrectnessError::PredicateViolation {
471 message: format!(
472 "`quantity` raw scale does not match size increment precision, quantity precision was {}, size increment precision was {}",
473 quantity.precision, increment.precision
474 ),
475 });
476 }
477
478 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
479 let scale = QuantityRaw::pow(10, u32::from(precision_diff));
480
481 if !quantity.raw.is_multiple_of(scale) {
482 return Err(CorrectnessError::PredicateViolation {
483 message: format!(
484 "`quantity` requires rounding to instrument size precision {precision}, was {quantity}"
485 ),
486 });
487 }
488
489 if increment.raw != 0 && !quantity.raw.is_multiple_of(increment.raw) {
490 return Err(CorrectnessError::PredicateViolation {
491 message: format!(
492 "`quantity` is not aligned to size increment {increment}, was {quantity}"
493 ),
494 });
495 }
496
497 Quantity::from_raw_checked(quantity.raw, precision)
498 }
499
500 fn try_calculate_base_quantity(
505 &self,
506 quantity: Quantity,
507 last_price: Price,
508 ) -> anyhow::Result<Quantity> {
509 let last_px = last_price.as_decimal();
510 if last_px.is_zero() {
511 anyhow::bail!("`last_price` was zero when calculating base quantity");
512 }
513 let precision = u32::from(self.min_size_increment_precision());
514 let value = (quantity.as_decimal() / last_px)
515 .round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
516 Quantity::from_decimal_dp(value, self.size_precision()).map_err(Into::into)
517 }
518
519 fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
524 self.try_calculate_base_quantity(quantity, last_price)
525 .unwrap()
526 }
527
528 #[inline(always)]
534 fn calculate_notional_value(
535 &self,
536 quantity: Quantity,
537 price: Price,
538 use_quote_for_inverse: Option<bool>,
539 ) -> Money {
540 let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
541 let (amount, currency) = if self.is_inverse() {
542 if use_quote_inverse {
543 (quantity.as_decimal(), self.quote_currency())
544 } else {
545 let amount =
546 quantity.as_decimal() * self.multiplier().as_decimal() / price.as_decimal();
547 let currency = self
548 .base_currency()
549 .expect("inverse instrument without base_currency");
550 (amount, currency)
551 }
552 } else if self.is_quanto() {
553 let amount =
554 quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
555 (amount, self.settlement_currency())
556 } else {
557 let amount =
558 quantity.as_decimal() * self.multiplier().as_decimal() * price.as_decimal();
559 (amount, self.quote_currency())
560 };
561
562 Money::from_decimal(amount, currency).expect("Invalid notional value")
563 }
564
565 #[inline(always)]
566 fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
567 if n < 0 {
568 return None;
569 }
570
571 let price = if let Some(scheme) = self.tick_scheme_rule() {
572 scheme.next_bid_price(value, n, self.price_precision())?
573 } else {
574 let value = Decimal::from_str(&value.to_string()).ok()?;
575 let increment = self.price_increment().as_decimal();
576 if increment.is_zero() {
577 return None;
578 }
579 let base = (value / increment).floor() * increment;
580 let result = base - Decimal::from(n) * increment;
581 Price::from_decimal_dp(result, self.price_precision()).ok()?
582 };
583
584 if self.min_price().is_some_and(|min| price < min)
585 || self.max_price().is_some_and(|max| price > max)
586 {
587 return None;
588 }
589
590 Some(price)
591 }
592
593 #[inline(always)]
594 fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
595 if n < 0 {
596 return None;
597 }
598
599 let price = if let Some(scheme) = self.tick_scheme_rule() {
600 scheme.next_ask_price(value, n, self.price_precision())?
601 } else {
602 let value = Decimal::from_str(&value.to_string()).ok()?;
603 let increment = self.price_increment().as_decimal();
604 if increment.is_zero() {
605 return None;
606 }
607 let base = (value / increment).ceil() * increment;
608 let result = base + Decimal::from(n) * increment;
609 Price::from_decimal_dp(result, self.price_precision()).ok()?
610 };
611
612 if self.min_price().is_some_and(|min| price < min)
613 || self.max_price().is_some_and(|max| price > max)
614 {
615 return None;
616 }
617
618 Some(price)
619 }
620
621 #[inline]
622 fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
623 let mut prices = Vec::with_capacity(n);
624
625 for i in 0..n {
626 let Ok(i) = i32::try_from(i) else { break };
627 if let Some(price) = self.next_bid_price(value, i) {
628 prices.push(price);
629 } else {
630 break;
631 }
632 }
633
634 prices
635 }
636
637 #[inline]
638 fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
639 let mut prices = Vec::with_capacity(n);
640
641 for i in 0..n {
642 let Ok(i) = i32::try_from(i) else { break };
643 if let Some(price) = self.next_ask_price(value, i) {
644 prices.push(price);
645 } else {
646 break;
647 }
648 }
649
650 prices
651 }
652}
653
654impl Display for CurrencyPair {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 write!(
657 f,
658 "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
659price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
660 stringify!(CurrencyPair),
661 self.id,
662 self.tick_scheme()
663 .map_or_else(|| "None".into(), |s| s.to_string()),
664 self.price_precision(),
665 self.size_precision(),
666 self.price_increment(),
667 self.size_increment(),
668 self.multiplier(),
669 self.margin_init(),
670 self.margin_maint(),
671 )
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use nautilus_core::correctness::{CorrectnessResultExt, FAILED};
678 use proptest::prelude::*;
679 use rstest::rstest;
680 use rust_decimal::{Decimal, prelude::*};
681
682 use super::*;
683 use crate::{
684 instruments::stubs::*,
685 types::{ERROR_PRICE, Money, PRICE_ERROR, PRICE_UNDEF, QUANTITY_UNDEF},
686 };
687
688 pub(super) fn default_price_increment(precision: u8) -> Price {
689 let step = 10f64.powi(-i32::from(precision));
690 Price::new(step, precision)
691 }
692
693 #[rstest]
694 fn default_increment_precision() {
695 let inc = default_price_increment(2);
696 assert_eq!(inc, Price::new(0.01, 2));
697 }
698
699 #[rstest]
700 #[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) {
711 assert_eq!(
712 nautilus_core::string::parsing::min_increment_precision_from_str(&price.to_string()),
713 expected
714 );
715 }
716
717 #[rstest]
718 #[case(1.5, "1.500000")]
719 #[case(2.5, "2.500000")]
720 #[case(1.234_567_8, "1.234568")]
721 #[case(0.000_123, "0.000123")]
722 #[case(99_999.999_999, "99999.999999")]
723 fn make_qty_rounding(
724 currency_pair_btcusdt: CurrencyPair,
725 #[case] input: f64,
726 #[case] expected: &str,
727 ) {
728 assert_eq!(
729 currency_pair_btcusdt.make_qty(input, None).to_string(),
730 expected
731 );
732 }
733
734 #[rstest]
735 #[case(1.234_567_8, "1.234567")]
736 #[case(1.999_999_9, "1.999999")]
737 #[case(0.000_123_45, "0.000123")]
738 #[case(10.999_999_9, "10.999999")]
739 fn make_qty_round_down(
740 currency_pair_btcusdt: CurrencyPair,
741 #[case] input: f64,
742 #[case] expected: &str,
743 ) {
744 assert_eq!(
745 currency_pair_btcusdt
746 .make_qty(input, Some(true))
747 .to_string(),
748 expected
749 );
750 }
751
752 #[rstest]
753 #[case(1.234_567_8, "1.23457")]
754 #[case(2.345_678_1, "2.34568")]
755 #[case(0.00001, "0.00001")]
756 fn make_qty_precision(
757 currency_pair_ethusdt: CurrencyPair,
758 #[case] input: f64,
759 #[case] expected: &str,
760 ) {
761 assert_eq!(
762 currency_pair_ethusdt.make_qty(input, None).to_string(),
763 expected
764 );
765 }
766
767 #[rstest]
768 #[case(1.234_567_5, "1.234568")]
769 #[case(1.234_566_5, "1.234566")]
770 fn make_qty_half_even(
771 currency_pair_btcusdt: CurrencyPair,
772 #[case] input: f64,
773 #[case] expected: &str,
774 ) {
775 assert_eq!(
776 currency_pair_btcusdt.make_qty(input, None).to_string(),
777 expected
778 );
779 }
780
781 #[rstest]
782 #[case(Price::from("10000"), "10000.00")]
783 #[case(Price::from("10000.0000"), "10000.00")]
784 fn try_normalize_price_rewrites_grid_aligned_values(
785 currency_pair_btcusdt: CurrencyPair,
786 #[case] input: Price,
787 #[case] expected: &str,
788 ) {
789 let normalized = currency_pair_btcusdt.try_normalize_price(input).unwrap();
790
791 assert_eq!(normalized.raw, input.raw);
792 assert_eq!(
793 normalized.precision,
794 currency_pair_btcusdt.price_precision()
795 );
796 assert_eq!(normalized, Price::from(expected));
797 }
798
799 #[rstest]
800 fn try_normalize_price_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
801 let error = currency_pair_btcusdt
802 .try_normalize_price(Price::from("10000.001"))
803 .unwrap_err();
804
805 assert!(matches!(
806 error,
807 CorrectnessError::PredicateViolation { ref message }
808 if message.contains("requires rounding to instrument price precision")
809 ));
810 }
811
812 #[rstest]
813 #[case(Price::from_raw(PRICE_UNDEF, 0), "PRICE_UNDEF")]
814 #[case(Price::from_raw(PRICE_ERROR, 0), "PRICE_ERROR")]
815 #[case(ERROR_PRICE, "ERROR_PRICE")]
816 fn try_normalize_price_rejects_sentinel_values(
817 currency_pair_btcusdt: CurrencyPair,
818 #[case] input: Price,
819 #[case] expected_value: &str,
820 ) {
821 let error = currency_pair_btcusdt
822 .try_normalize_price(input)
823 .unwrap_err();
824
825 match error {
826 CorrectnessError::InvalidValue {
827 param,
828 value,
829 type_name,
830 } => {
831 assert_eq!(param, "price");
832 assert_eq!(value, expected_value);
833 assert_eq!(type_name, "`Price`");
834 }
835 _ => panic!("expected invalid price error, was {error}"),
836 }
837 }
838
839 #[rstest]
840 #[case(Price::from("-10000"), Some(Price::from("-10000.00")))]
841 #[case(Price::from("-10000.001"), None)]
842 fn try_normalize_price_handles_negative_values(
843 currency_pair_btcusdt: CurrencyPair,
844 #[case] input: Price,
845 #[case] expected: Option<Price>,
846 ) {
847 let normalized = currency_pair_btcusdt.try_normalize_price(input).ok();
848
849 assert_eq!(normalized, expected);
850 }
851
852 #[rstest]
853 fn try_normalize_price_rejects_sub_increment_value() {
854 let instrument = CurrencyPair::new(
855 InstrumentId::from("TEST.VENUE"),
856 Symbol::from("TEST"),
857 Currency::from("BTC"),
858 Currency::from("USD"),
859 2,
860 2,
861 Price::from("0.50"),
862 Quantity::from("0.01"),
863 None,
864 None,
865 None,
866 None,
867 None,
868 None,
869 None,
870 None,
871 None,
872 None,
873 None,
874 None,
875 None,
876 None, UnixNanos::default(),
878 UnixNanos::default(),
879 );
880
881 assert_eq!(
882 instrument.try_normalize_price(Price::from("1.500")),
883 Ok(Price::from("1.50"))
884 );
885 let error = instrument
886 .try_normalize_price(Price::from("1.20"))
887 .unwrap_err();
888
889 assert!(matches!(
890 error,
891 CorrectnessError::PredicateViolation { ref message }
892 if message.contains("not aligned to price increment")
893 ));
894 }
895
896 #[rstest]
897 #[case(Quantity::from("1"), "1.000000")]
898 #[case(Quantity::from("1.0000000"), "1.000000")]
899 fn try_normalize_qty_rewrites_grid_aligned_values(
900 currency_pair_btcusdt: CurrencyPair,
901 #[case] input: Quantity,
902 #[case] expected: &str,
903 ) {
904 let normalized = currency_pair_btcusdt.try_normalize_qty(input).unwrap();
905
906 assert_eq!(normalized.raw, input.raw);
907 assert_eq!(normalized.precision, currency_pair_btcusdt.size_precision());
908 assert_eq!(normalized, Quantity::from(expected));
909 }
910
911 #[rstest]
912 fn try_normalize_qty_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
913 let error = currency_pair_btcusdt
914 .try_normalize_qty(Quantity::from("1.0000001"))
915 .unwrap_err();
916
917 assert!(matches!(
918 error,
919 CorrectnessError::PredicateViolation { ref message }
920 if message.contains("requires rounding to instrument size precision")
921 ));
922 }
923
924 #[rstest]
925 fn try_normalize_qty_rejects_undefined_value(currency_pair_btcusdt: CurrencyPair) {
926 let error = currency_pair_btcusdt
927 .try_normalize_qty(Quantity::from_raw(QUANTITY_UNDEF, 0))
928 .unwrap_err();
929
930 match error {
931 CorrectnessError::InvalidValue {
932 param,
933 value,
934 type_name,
935 } => {
936 assert_eq!(param, "quantity");
937 assert_eq!(value, "QUANTITY_UNDEF");
938 assert_eq!(type_name, "`Quantity`");
939 }
940 _ => panic!("expected invalid quantity error, was {error}"),
941 }
942 }
943
944 #[cfg(feature = "defi")]
945 #[rstest]
946 fn try_normalize_values_reject_mixed_raw_scales() {
947 let defi_precision = 18;
948 let price_increment = Price::from_raw(PriceRaw::from(5) * PriceRaw::pow(10, 17), 18);
949 let size_increment =
950 Quantity::from_raw(QuantityRaw::from(5_u8) * QuantityRaw::pow(10, 17), 18);
951 let instrument = CurrencyPair::new(
952 InstrumentId::from("TEST.VENUE"),
953 Symbol::from("TEST"),
954 Currency::from("BTC"),
955 Currency::from("USD"),
956 defi_precision,
957 defi_precision,
958 price_increment,
959 size_increment,
960 None,
961 None,
962 None,
963 None,
964 None,
965 None,
966 None,
967 None,
968 None,
969 None,
970 None,
971 None,
972 None,
973 None, UnixNanos::default(),
975 UnixNanos::default(),
976 );
977 let fixed_scale = u32::from(FIXED_PRECISION);
978 let fixed_price = Price::from_raw(
979 PriceRaw::pow(10, fixed_scale) * PriceRaw::from(100),
980 FIXED_PRECISION,
981 );
982 let fixed_qty = Quantity::from_raw(
983 QuantityRaw::pow(10, fixed_scale) * QuantityRaw::from(100_u8),
984 FIXED_PRECISION,
985 );
986
987 let price_error = instrument.try_normalize_price(fixed_price).unwrap_err();
988 let qty_error = instrument.try_normalize_qty(fixed_qty).unwrap_err();
989
990 assert!(matches!(
991 price_error,
992 CorrectnessError::PredicateViolation { ref message }
993 if message.contains("raw scale does not match instrument price precision")
994 ));
995 assert!(matches!(
996 qty_error,
997 CorrectnessError::PredicateViolation { ref message }
998 if message.contains("raw scale does not match instrument size precision")
999 ));
1000 }
1001
1002 #[rstest]
1003 fn try_normalize_qty_rejects_sub_increment_value() {
1004 let instrument = CurrencyPair::new(
1005 InstrumentId::from("TEST.VENUE"),
1006 Symbol::from("TEST"),
1007 Currency::from("BTC"),
1008 Currency::from("USD"),
1009 2,
1010 2,
1011 Price::from("0.01"),
1012 Quantity::from("0.50"),
1013 None,
1014 None,
1015 None,
1016 None,
1017 None,
1018 None,
1019 None,
1020 None,
1021 None,
1022 None,
1023 None,
1024 None,
1025 None,
1026 None, UnixNanos::default(),
1028 UnixNanos::default(),
1029 );
1030
1031 assert_eq!(
1032 instrument.try_normalize_qty(Quantity::from("1.500")),
1033 Ok(Quantity::from("1.50"))
1034 );
1035 let error = instrument
1036 .try_normalize_qty(Quantity::from("1.20"))
1037 .unwrap_err();
1038
1039 assert!(matches!(
1040 error,
1041 CorrectnessError::PredicateViolation { ref message }
1042 if message.contains("not aligned to size increment")
1043 ));
1044 }
1045
1046 #[rstest]
1047 #[should_panic(expected = "value rounded to zero")]
1048 fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
1049 currency_pair_btcusdt.make_qty(1e-12, None);
1050 }
1051
1052 #[rstest]
1053 fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
1054 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1055 let price = currency_pair_btcusdt.make_price(10_000.0);
1056 let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
1057 let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
1058 assert_eq!(notional, expected);
1059 }
1060
1061 #[rstest]
1062 fn currency_pair_is_not_quanto(currency_pair_btcusdt: CurrencyPair) {
1063 assert!(!currency_pair_btcusdt.is_quanto());
1064 assert_eq!(currency_pair_btcusdt.cost_currency(), Currency::USDT());
1065 }
1066
1067 #[rstest]
1068 fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
1069 let start = 10_000.123_4;
1070 let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
1071 let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
1072 assert!(bid_1 < bid_0);
1073 let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
1074 assert_eq!(asks.len(), 3);
1075 assert!(asks[0] > bid_0);
1076 }
1077
1078 #[rstest]
1079 fn tick_navigation_uses_tick_scheme() {
1080 let instrument = CurrencyPair::new(
1081 InstrumentId::from("TEST.VENUE"),
1082 Symbol::from("TEST"),
1083 Currency::from("BTC"),
1084 Currency::from("USD"),
1085 2,
1086 2,
1087 Price::new(0.01, 2),
1088 Quantity::from("0.01"),
1089 None,
1090 None,
1091 None,
1092 None,
1093 None,
1094 None,
1095 None,
1096 None,
1097 None,
1098 None,
1099 None,
1100 None,
1101 Some(Ustr::from("FIXED_PRECISION_1")),
1102 None,
1103 UnixNanos::default(),
1104 UnixNanos::default(),
1105 );
1106
1107 assert_eq!(
1108 instrument.tick_scheme(),
1109 Some(Ustr::from("FIXED_PRECISION_1"))
1110 );
1111 assert_eq!(instrument.next_bid_price(1.23, 0), Some(Price::new(1.2, 2)));
1112 assert_eq!(instrument.next_ask_price(1.23, 0), Some(Price::new(1.3, 2)));
1113 }
1114
1115 #[rstest]
1116 #[case("BOGUS")]
1117 #[case("FIXED_PRECISION_99")]
1118 fn invalid_tick_scheme_returns_error(#[case] tick_scheme: &str) {
1119 let err = CurrencyPair::new_checked(
1120 InstrumentId::from("TEST.VENUE"),
1121 Symbol::from("TEST"),
1122 Currency::from("BTC"),
1123 Currency::from("USD"),
1124 2,
1125 2,
1126 Price::new(0.01, 2),
1127 Quantity::from("0.01"),
1128 None,
1129 None,
1130 None,
1131 None,
1132 None,
1133 None,
1134 None,
1135 None,
1136 None,
1137 None,
1138 None,
1139 None,
1140 Some(Ustr::from(tick_scheme)),
1141 None,
1142 UnixNanos::default(),
1143 UnixNanos::default(),
1144 )
1145 .expect_err("invalid tick scheme must fail");
1146
1147 assert!(
1148 err.to_string()
1149 .contains("tick_scheme not found in tick schemes"),
1150 "{err}"
1151 );
1152 }
1153
1154 #[rstest]
1155 #[should_panic(expected = "'margin_init' not positive")]
1156 fn validate_negative_margin_init() {
1157 let size_increment = Quantity::new(0.01, 2);
1158 let multiplier = Quantity::new(1.0, 0);
1159
1160 validate_instrument_common(
1161 2,
1162 2, size_increment, multiplier, dec!(-0.01), dec!(0.01), None, None, None, None, None, None, None, None, )
1176 .expect_display(FAILED);
1177 }
1178
1179 #[rstest]
1180 #[should_panic(expected = "'margin_maint' not positive")]
1181 fn validate_negative_margin_maint() {
1182 let size_increment = Quantity::new(0.01, 2);
1183 let multiplier = Quantity::new(1.0, 0);
1184
1185 validate_instrument_common(
1186 2,
1187 2, size_increment, multiplier, dec!(0.01), dec!(-0.01), None, None, None, None, None, None, None, None, )
1201 .expect_display(FAILED);
1202 }
1203
1204 #[rstest]
1205 #[should_panic(expected = "'margin_init' not positive")]
1206 fn validate_negative_max_qty() {
1207 let quantity = Quantity::new(0.0, 0);
1208 validate_instrument_common(
1209 2,
1210 2,
1211 Quantity::new(0.01, 2),
1212 Quantity::new(1.0, 0),
1213 dec!(0),
1214 dec!(0),
1215 None,
1216 None,
1217 Some(quantity),
1218 None,
1219 None,
1220 None,
1221 None,
1222 None,
1223 )
1224 .expect_display(FAILED);
1225 }
1226
1227 #[rstest]
1228 fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
1229 let price = currency_pair_ethusdt.make_price(-123.456_789);
1230 assert!(price.as_f64() < 0.0);
1231 }
1232
1233 #[rstest]
1234 fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
1235 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1236 let price = currency_pair_btcusdt.make_price(10_000.0);
1237 let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
1238 assert_eq!(base.to_string(), "0.000200");
1239 }
1240
1241 #[rstest]
1242 fn base_quantity_zero_last_price_returns_error(currency_pair_btcusdt: CurrencyPair) {
1243 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1244 let error = currency_pair_btcusdt
1245 .try_calculate_base_quantity(quantity, Price::new(0.0, 2))
1246 .unwrap_err();
1247 assert!(
1248 error.to_string().contains("`last_price` was zero"),
1249 "{error}"
1250 );
1251 }
1252
1253 #[rstest]
1254 #[case(f64::NAN)]
1255 #[case(f64::INFINITY)]
1256 #[case(1e30)] fn make_price_invalid_value_returns_error(
1258 currency_pair_btcusdt: CurrencyPair,
1259 #[case] value: f64,
1260 ) {
1261 let error = currency_pair_btcusdt.try_make_price(value).unwrap_err();
1262 assert!(
1263 error.to_string().contains("invalid `value` for make_price"),
1264 "{error}"
1265 );
1266 }
1267
1268 #[rstest]
1269 fn make_qty_invalid_value_returns_error(currency_pair_btcusdt: CurrencyPair) {
1270 let error = currency_pair_btcusdt
1271 .try_make_qty(f64::NAN, None)
1272 .unwrap_err();
1273 assert!(
1274 error.to_string().contains("invalid `value` for make_qty"),
1275 "{error}"
1276 );
1277 }
1278
1279 #[rstest]
1280 fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1281 let start = 10_000.0;
1282 let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
1283 assert_eq!(bids.len(), 5);
1284 for i in 1..bids.len() {
1285 assert!(bids[i] < bids[i - 1]);
1286 }
1287 }
1288
1289 #[rstest]
1290 fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1291 let start = 10_000.0;
1292 let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
1293 assert_eq!(asks.len(), 5);
1294 for i in 1..asks.len() {
1295 assert!(asks[i] > asks[i - 1]);
1296 }
1297 }
1298
1299 #[rstest]
1300 #[should_panic(expected = "'margin_init' not positive")]
1301 fn validate_price_increment_precision_mismatch() {
1302 let size_increment = Quantity::new(0.01, 2);
1303 let multiplier = Quantity::new(1.0, 0);
1304 let price_increment = Price::new(0.001, 3);
1305 validate_instrument_common(
1306 2,
1307 2,
1308 size_increment,
1309 multiplier,
1310 dec!(0),
1311 dec!(0),
1312 Some(price_increment),
1313 None,
1314 None,
1315 None,
1316 None,
1317 None,
1318 None,
1319 None,
1320 )
1321 .expect_display(FAILED);
1322 }
1323
1324 #[rstest]
1325 #[should_panic(expected = "'margin_init' not positive")]
1326 fn validate_min_price_exceeds_max_price() {
1327 let size_increment = Quantity::new(0.01, 2);
1328 let multiplier = Quantity::new(1.0, 0);
1329 let min_price = Price::new(10.0, 2);
1330 let max_price = Price::new(5.0, 2);
1331 validate_instrument_common(
1332 2,
1333 2,
1334 size_increment,
1335 multiplier,
1336 dec!(0),
1337 dec!(0),
1338 None,
1339 None,
1340 None,
1341 None,
1342 None,
1343 None,
1344 Some(max_price),
1345 Some(min_price),
1346 )
1347 .expect_display(FAILED);
1348 }
1349
1350 #[rstest]
1351 fn validate_instrument_common_ok() {
1352 let res = validate_instrument_common(
1353 2,
1354 4,
1355 Quantity::new(0.0001, 4),
1356 Quantity::new(1.0, 0),
1357 dec!(0.02),
1358 dec!(0.01),
1359 Some(Price::new(0.01, 2)),
1360 None,
1361 None,
1362 None,
1363 None,
1364 None,
1365 None,
1366 None,
1367 );
1368 assert!(matches!(res, Ok(())));
1369 }
1370
1371 #[rstest]
1372 #[should_panic(expected = "not in range")]
1373 fn validate_multiple_errors() {
1374 validate_instrument_common(
1375 2,
1376 2,
1377 Quantity::new(-0.01, 2),
1378 Quantity::new(0.0, 0),
1379 dec!(0),
1380 dec!(0),
1381 None,
1382 None,
1383 None,
1384 None,
1385 None,
1386 None,
1387 None,
1388 None,
1389 )
1390 .expect_display(FAILED);
1391 }
1392
1393 #[rstest]
1394 #[case(1.234_999_9, false, "1.235000")]
1395 #[case(1.234_999_9, true, "1.234999")]
1396 fn make_qty_boundary(
1397 currency_pair_btcusdt: CurrencyPair,
1398 #[case] input: f64,
1399 #[case] round_down: bool,
1400 #[case] expected: &str,
1401 ) {
1402 let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
1403 assert_eq!(quantity.to_string(), expected);
1404 }
1405
1406 #[rstest]
1407 #[case(1.234_999, 1.23)]
1408 #[case(1.235, 1.24)]
1409 #[case(1.235_001, 1.24)]
1410 fn make_price_rounding_parity(
1411 currency_pair_btcusdt: CurrencyPair,
1412 #[case] input: f64,
1413 #[case] expected: f64,
1414 ) {
1415 let price = currency_pair_btcusdt.make_price(input);
1416 assert!((price.as_f64() - expected).abs() < 1e-9);
1417 }
1418
1419 #[rstest]
1420 fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
1421 let rounding_precision = std::cmp::min(
1422 currency_pair_btcusdt.price_precision(),
1423 currency_pair_btcusdt.min_price_increment_precision(),
1424 );
1425 let step = 10f64.powi(-i32::from(rounding_precision));
1426 let base_even_multiple = 42.0;
1427 let base_value = step * base_even_multiple;
1428 let delta = step / 2000.0;
1429 let value_below = base_value + 0.5 * step - delta;
1430 let value_exact = base_value + 0.5 * step;
1431 let value_above = base_value + 0.5 * step + delta;
1432 let price_below = currency_pair_btcusdt.make_price(value_below);
1433 let price_exact = currency_pair_btcusdt.make_price(value_exact);
1434 let price_above = currency_pair_btcusdt.make_price(value_above);
1435 assert_eq!(price_below, price_exact);
1436 assert_ne!(price_exact, price_above);
1437 }
1438
1439 #[rstest]
1440 fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
1441 assert!(ethbtc_quanto.is_quanto());
1442 }
1443
1444 #[rstest]
1445 fn notional_quanto(ethbtc_quanto: CryptoFuture) {
1446 let quantity = ethbtc_quanto.make_qty(5.0, None);
1447 let price = ethbtc_quanto.make_price(0.036);
1448 let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
1449 let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
1450 assert_eq!(notional, expected);
1451 }
1452
1453 #[rstest]
1454 #[case("USD", "BUSD")]
1455 #[case("USD", "FDUSD")]
1456 #[case("USD", "pUSD")]
1457 #[case("USD", "TUSD")]
1458 #[case("USD", "USD")]
1459 #[case("USD", "USDC")]
1460 #[case("USD", "USDC.e")]
1461 #[case("USD", "USDP")]
1462 #[case("USD", "USDT")]
1463 #[case("BUSD", "USD")]
1464 #[case("FDUSD", "USD")]
1465 #[case("pUSD", "USD")]
1466 #[case("TUSD", "USD")]
1467 #[case("USDC", "USD")]
1468 #[case("USDC.e", "USD")]
1469 #[case("USDP", "USD")]
1470 #[case("USDT", "USD")]
1471 fn usd_equivalent_settlement_is_not_quanto(
1472 #[case] quote_currency_code: &str,
1473 #[case] settlement_currency_code: &str,
1474 ) {
1475 let quote_currency =
1476 Currency::try_from_str(quote_currency_code).expect("quote currency must exist");
1477 let settlement_currency = Currency::try_from_str(settlement_currency_code)
1478 .expect("settlement currency must exist");
1479 let instrument = crypto_future_with_quote_settlement(quote_currency, settlement_currency);
1480 let quantity = instrument.make_qty(5.0, None);
1481 let price = instrument.make_price(1000.0);
1482 let notional = instrument.calculate_notional_value(quantity, price, None);
1483
1484 assert!(!instrument.is_quanto());
1485 assert_eq!(instrument.cost_currency(), quote_currency);
1486 assert_eq!(notional, Money::new(5000.0, quote_currency));
1487 }
1488
1489 #[rstest]
1490 fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
1491 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1492 let price = xbtusd_inverse_perp.make_price(50_000.0);
1493 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
1494 let expected = Money::new(
1495 100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
1496 xbtusd_inverse_perp.base_currency().unwrap(),
1497 );
1498 assert_eq!(notional, expected);
1499 }
1500
1501 #[rstest]
1502 fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
1503 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1504 let price = xbtusd_inverse_perp.make_price(50_000.0);
1505 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
1506 let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
1507 assert_eq!(notional, expected);
1508 }
1509
1510 #[rstest]
1511 #[should_panic(expected = "'margin_init' not positive")]
1512 fn validate_non_positive_max_price() {
1513 let size_increment = Quantity::new(0.01, 2);
1514 let multiplier = Quantity::new(1.0, 0);
1515 let max_price = Price::new(0.0, 2);
1516 validate_instrument_common(
1517 2,
1518 2,
1519 size_increment,
1520 multiplier,
1521 dec!(0),
1522 dec!(0),
1523 None,
1524 None,
1525 None,
1526 None,
1527 None,
1528 None,
1529 Some(max_price),
1530 None,
1531 )
1532 .expect_display(FAILED);
1533 }
1534
1535 #[rstest]
1536 #[should_panic(expected = "'margin_init' not positive")]
1537 fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1538 let size_increment = Quantity::new(0.01, 2);
1539 let multiplier = Quantity::new(1.0, 0);
1540 let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1541 validate_instrument_common(
1542 2,
1543 2,
1544 size_increment,
1545 multiplier,
1546 dec!(0),
1547 dec!(0),
1548 None,
1549 None,
1550 None,
1551 None,
1552 Some(max_notional),
1553 None,
1554 None,
1555 None,
1556 )
1557 .expect_display(FAILED);
1558 }
1559
1560 #[rstest]
1561 #[should_panic(expected = "'margin_init' not positive")]
1562 fn validate_price_increment_min_price_precision_mismatch() {
1563 let size_increment = Quantity::new(0.01, 2);
1564 let multiplier = Quantity::new(1.0, 0);
1565 let price_increment = Price::new(0.01, 2);
1566 let min_price = Price::new(1.0, 3);
1567 validate_instrument_common(
1568 2,
1569 2,
1570 size_increment,
1571 multiplier,
1572 dec!(0),
1573 dec!(0),
1574 Some(price_increment),
1575 None,
1576 None,
1577 None,
1578 None,
1579 None,
1580 None,
1581 Some(min_price),
1582 )
1583 .expect_display(FAILED);
1584 }
1585
1586 #[rstest]
1587 #[should_panic(expected = "'margin_init' not positive")]
1588 fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1589 let size_increment = Quantity::new(0.01, 2);
1590 let multiplier = Quantity::new(1.0, 0);
1591 let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1592 let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1593 validate_instrument_common(
1594 2,
1595 2,
1596 size_increment,
1597 multiplier,
1598 dec!(0),
1599 dec!(0),
1600 None,
1601 None,
1602 None,
1603 None,
1604 Some(max_notional),
1605 Some(min_notional),
1606 None,
1607 None,
1608 )
1609 .expect_display(FAILED);
1610 }
1611
1612 #[rstest]
1613 #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1614 #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1615 #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1616 #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1617 #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1618 #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1619 #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1620 #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1621 #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1622 fn base_qty_rounding(
1623 currency_pair_btcusdt: CurrencyPair,
1624 #[case] q: Decimal,
1625 #[case] px: Decimal,
1626 #[case] expected: f64,
1627 ) {
1628 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1629 let price = Price::new(px.to_f64().unwrap(), 8);
1630 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1631 assert!((base.as_f64() - expected).abs() < 1e-9);
1632 }
1633
1634 proptest! {
1635 #[rstest]
1636 fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1637 let instrument = currency_pair_btcusdt();
1638 let price = instrument.make_price(input);
1639 prop_assert!(price.as_f64().is_finite());
1640 let quantity = instrument.make_qty(input, None);
1641 prop_assert!(quantity.as_f64().is_finite());
1642 }
1643 }
1644
1645 #[rstest]
1646 fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1647 if let Some(max_price) = currency_pair_btcusdt.max_price() {
1648 assert!(
1649 currency_pair_btcusdt
1650 .next_ask_price(max_price.as_f64(), 1)
1651 .is_none()
1652 );
1653 }
1654 }
1655
1656 #[rstest]
1657 fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1658 if let Some(max_price) = currency_pair_ethusdt.max_price() {
1659 assert!(
1660 currency_pair_ethusdt
1661 .next_ask_price(max_price.as_f64(), 1)
1662 .is_none()
1663 );
1664 }
1665 }
1666
1667 #[rstest]
1668 fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1669 if let Some(min_price) = currency_pair_btcusdt.min_price() {
1670 assert!(
1671 currency_pair_btcusdt
1672 .next_bid_price(min_price.as_f64(), 1)
1673 .is_none()
1674 );
1675 }
1676 }
1677
1678 #[rstest]
1679 fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1680 if let Some(min_price) = currency_pair_ethusdt.min_price() {
1681 assert!(
1682 currency_pair_ethusdt
1683 .next_bid_price(min_price.as_f64(), 1)
1684 .is_none()
1685 );
1686 }
1687 }
1688
1689 #[rstest]
1690 fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1691 if let Some(max_price) = ethbtc_quanto.max_price() {
1692 assert!(
1693 ethbtc_quanto
1694 .next_ask_price(max_price.as_f64(), 1)
1695 .is_none()
1696 );
1697 }
1698 }
1699
1700 #[rstest]
1701 #[case(0.999_999, false)]
1702 #[case(0.999_999, true)]
1703 #[case(1.000_000_1, false)]
1704 #[case(1.000_000_1, true)]
1705 #[case(1.234_5, false)]
1706 #[case(1.234_5, true)]
1707 #[case(2.345_5, false)]
1708 #[case(2.345_5, true)]
1709 #[case(0.000_999_999, false)]
1710 #[case(0.000_999_999, true)]
1711 fn quantity_rounding_grid(
1712 currency_pair_btcusdt: CurrencyPair,
1713 #[case] input: f64,
1714 #[case] round_down: bool,
1715 ) {
1716 let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1717 assert!(qty.as_f64().is_finite());
1718 }
1719
1720 #[rstest]
1721 fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1722 let size_increment = Quantity::new(0.01, 2);
1723 let multiplier = Quantity::new(1.0, 0);
1724 let price_increment = Price::new(0.01, 2);
1725 let max_price = Price::new(1.0, 3);
1726 let res = validate_instrument_common(
1727 2,
1728 2,
1729 size_increment,
1730 multiplier,
1731 dec!(0),
1732 dec!(0),
1733 Some(price_increment),
1734 None,
1735 None,
1736 None,
1737 None,
1738 None,
1739 Some(max_price),
1740 None,
1741 );
1742 assert!(res.is_err());
1743 }
1744
1745 #[rstest]
1746 #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1747 #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1748 #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1749 #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1750 #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1751 #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1752 #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1753 #[case::dp16(
1754 Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1755 Decimal::new(2, 0),
1756 500.0
1757 )]
1758 #[case::dp17(
1759 Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1760 Decimal::new(2, 0),
1761 500.0
1762 )]
1763 fn base_qty_rounding_high_dp(
1764 currency_pair_btcusdt: CurrencyPair,
1765 #[case] q: Decimal,
1766 #[case] px: Decimal,
1767 #[case] expected: f64,
1768 ) {
1769 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1770 let price = Price::new(px.to_f64().unwrap(), 8);
1771 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1772 assert!((base.as_f64() - expected).abs() < 1e-9);
1773 }
1774
1775 #[rstest]
1776 fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1777 let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1778 assert!(check_positive_money(money, "money").is_ok());
1779 }
1780
1781 #[rstest]
1782 #[should_panic(expected = "NotPositive")]
1783 fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1784 let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1785 check_positive_money(money, "money").unwrap();
1786 }
1787
1788 #[rstest]
1789 #[should_panic(expected = "NotPositive")]
1790 fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1791 let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1792 check_positive_money(money, "money").unwrap();
1793 }
1794
1795 fn crypto_future_with_quote_settlement(
1796 quote_currency: Currency,
1797 settlement_currency: Currency,
1798 ) -> CryptoFuture {
1799 CryptoFuture::new(
1800 InstrumentId::from("ETHUSD-QUANTO-TEST.BINANCE"),
1801 Symbol::from("ETHUSD-QUANTO-TEST"),
1802 Currency::ETH(),
1803 quote_currency,
1804 settlement_currency,
1805 false,
1806 0.into(),
1807 0.into(),
1808 2,
1809 0,
1810 Price::from("0.01"),
1811 Quantity::from("1"),
1812 None,
1813 None,
1814 None,
1815 None,
1816 None,
1817 None,
1818 None,
1819 None,
1820 None,
1821 None,
1822 None,
1823 None,
1824 None,
1825 None,
1826 0.into(),
1827 0.into(),
1828 )
1829 }
1830
1831 #[rstest]
1832 fn make_price_with_trailing_zeros_in_increment() {
1833 let instrument = CurrencyPair::new(
1836 InstrumentId::from("TEST.VENUE"),
1837 Symbol::from("TEST"),
1838 Currency::from("BTC"),
1839 Currency::from("USD"),
1840 2, 2, Price::new(0.50, 2), Quantity::from("0.01"),
1844 None,
1845 None,
1846 None,
1847 None,
1848 None,
1849 None,
1850 None,
1851 None,
1852 None,
1853 None,
1854 None,
1855 None,
1856 None,
1857 None, UnixNanos::default(),
1859 UnixNanos::default(),
1860 );
1861
1862 assert_eq!(instrument.min_price_increment_precision(), 1);
1864
1865 let price = instrument.make_price(1.234);
1868 assert_eq!(price.as_f64(), 1.2);
1869
1870 let price = instrument.make_price(1.25);
1872 assert_eq!(price.as_f64(), 1.2);
1873
1874 let price = instrument.make_price(1.35);
1876 assert_eq!(price.as_f64(), 1.4);
1877
1878 assert_eq!(price.precision, 2);
1880 }
1881
1882 #[rstest]
1883 fn make_qty_with_trailing_zeros_in_increment() {
1884 let instrument = CurrencyPair::new(
1886 InstrumentId::from("TEST.VENUE"),
1887 Symbol::from("TEST"),
1888 Currency::from("BTC"),
1889 Currency::from("USD"),
1890 2, 2, Price::new(0.01, 2),
1893 Quantity::new(0.50, 2), None,
1895 None,
1896 None,
1897 None,
1898 None,
1899 None,
1900 None,
1901 None,
1902 None,
1903 None,
1904 None,
1905 None,
1906 None,
1907 None, UnixNanos::default(),
1909 UnixNanos::default(),
1910 );
1911
1912 assert_eq!(instrument.min_size_increment_precision(), 1);
1914
1915 let qty = instrument.make_qty(1.234, None);
1918 assert_eq!(qty.as_f64(), 1.2);
1919
1920 let qty = instrument.make_qty(1.25, None);
1922 assert_eq!(qty.as_f64(), 1.2);
1923
1924 let qty = instrument.make_qty(1.35, None);
1926 assert_eq!(qty.as_f64(), 1.4);
1927
1928 assert_eq!(qty.precision, 2);
1930
1931 let qty = instrument.make_qty(1.99, Some(true));
1933 assert_eq!(qty.as_f64(), 1.9);
1934 }
1935
1936 #[rstest]
1937 #[case(InstrumentClass::Future, true)]
1938 #[case(InstrumentClass::FuturesSpread, true)]
1939 #[case(InstrumentClass::Option, true)]
1940 #[case(InstrumentClass::OptionSpread, true)]
1941 #[case(InstrumentClass::Spot, false)]
1942 #[case(InstrumentClass::Swap, false)]
1943 #[case(InstrumentClass::Forward, false)]
1944 #[case(InstrumentClass::Cfd, false)]
1945 #[case(InstrumentClass::Bond, false)]
1946 #[case(InstrumentClass::Warrant, false)]
1947 #[case(InstrumentClass::SportsBetting, false)]
1948 #[case(InstrumentClass::BinaryOption, false)]
1949 fn test_instrument_class_has_expiration(
1950 #[case] instrument_class: InstrumentClass,
1951 #[case] expected: bool,
1952 ) {
1953 assert_eq!(instrument_class.has_expiration(), expected);
1954 }
1955}