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)]
319 fn try_make_price_from_decimal(&self, value: Decimal) -> anyhow::Result<Price> {
320 let precision = u32::from(self.min_price_increment_precision());
321 let rounded_decimal =
322 value.round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
323 Price::from_decimal_dp(rounded_decimal, self.price_precision()).map_err(Into::into)
324 }
325
326 fn make_price_from_decimal(&self, value: Decimal) -> Price {
330 self.try_make_price_from_decimal(value).unwrap()
331 }
332
333 #[inline(always)]
338 fn try_make_price(&self, value: f64) -> anyhow::Result<Price> {
339 let dec_value = Decimal::from_str(&value.to_string())
340 .map_err(|_| anyhow::anyhow!("invalid `value` for make_price, was {value}"))?;
341 self.try_make_price_from_decimal(dec_value)
342 }
343
344 fn make_price(&self, value: f64) -> Price {
348 self.try_make_price(value).unwrap()
349 }
350
351 #[inline(always)]
357 fn try_normalize_price(&self, price: Price) -> CorrectnessResult<Price> {
358 if price == ERROR_PRICE {
359 return Err(CorrectnessError::InvalidValue {
360 param: "price".to_string(),
361 value: "ERROR_PRICE".to_string(),
362 type_name: "`Price`",
363 });
364 }
365
366 if price.raw == PRICE_ERROR {
367 return Err(CorrectnessError::InvalidValue {
368 param: "price".to_string(),
369 value: "PRICE_ERROR".to_string(),
370 type_name: "`Price`",
371 });
372 }
373
374 if price.is_undefined() {
375 return Err(CorrectnessError::InvalidValue {
376 param: "price".to_string(),
377 value: "PRICE_UNDEF".to_string(),
378 type_name: "`Price`",
379 });
380 }
381
382 let precision = self.price_precision();
383 let increment = self.price_increment();
384
385 if !raw_scales_match(price.precision, precision) {
386 return Err(CorrectnessError::PredicateViolation {
387 message: format!(
388 "`price` raw scale does not match instrument price precision, price precision was {}, instrument price precision was {precision}",
389 price.precision
390 ),
391 });
392 }
393
394 if !raw_scales_match(price.precision, increment.precision) {
395 return Err(CorrectnessError::PredicateViolation {
396 message: format!(
397 "`price` raw scale does not match price increment precision, price precision was {}, price increment precision was {}",
398 price.precision, increment.precision
399 ),
400 });
401 }
402
403 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
404 let scale = PriceRaw::pow(10, u32::from(precision_diff));
405
406 if price.raw % scale != 0 {
407 return Err(CorrectnessError::PredicateViolation {
408 message: format!(
409 "`price` requires rounding to instrument price precision {precision}, was {price}"
410 ),
411 });
412 }
413
414 let increment_raw = increment.raw.abs();
415 if increment_raw != 0 && price.raw % increment_raw != 0 {
416 return Err(CorrectnessError::PredicateViolation {
417 message: format!(
418 "`price` is not aligned to price increment {increment}, was {price}"
419 ),
420 });
421 }
422
423 Price::from_raw_checked(price.raw, precision)
424 }
425
426 #[inline(always)]
430 fn try_make_qty_from_decimal(
431 &self,
432 value: Decimal,
433 round_down: Option<bool>,
434 ) -> anyhow::Result<Quantity> {
435 let precision = u32::from(self.min_size_increment_precision());
436
437 let strategy = if round_down.unwrap_or(false) {
438 RoundingStrategy::ToZero
439 } else {
440 RoundingStrategy::MidpointNearestEven
441 };
442
443 let rounded = value.round_dp_with_strategy(precision, strategy);
444 if value > Decimal::ZERO && rounded.is_zero() {
445 anyhow::bail!("value rounded to zero for quantity");
446 }
447
448 Quantity::from_decimal_dp(rounded, self.size_precision()).map_err(Into::into)
449 }
450
451 fn make_qty_from_decimal(&self, value: Decimal, round_down: Option<bool>) -> Quantity {
455 self.try_make_qty_from_decimal(value, round_down).unwrap()
456 }
457
458 #[inline(always)]
463 fn try_make_qty(&self, value: f64, round_down: Option<bool>) -> anyhow::Result<Quantity> {
464 let dec_value = Decimal::from_str(&value.to_string())
465 .map_err(|_| anyhow::anyhow!("invalid `value` for make_qty, was {value}"))?;
466 self.try_make_qty_from_decimal(dec_value, round_down)
467 }
468
469 fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
473 self.try_make_qty(value, round_down).unwrap()
474 }
475
476 #[inline(always)]
482 fn try_normalize_qty(&self, quantity: Quantity) -> CorrectnessResult<Quantity> {
483 if quantity.is_undefined() {
484 return Err(CorrectnessError::InvalidValue {
485 param: "quantity".to_string(),
486 value: "QUANTITY_UNDEF".to_string(),
487 type_name: "`Quantity`",
488 });
489 }
490
491 let precision = self.size_precision();
492 let increment = self.size_increment();
493
494 if !raw_scales_match(quantity.precision, precision) {
495 return Err(CorrectnessError::PredicateViolation {
496 message: format!(
497 "`quantity` raw scale does not match instrument size precision, quantity precision was {}, instrument size precision was {precision}",
498 quantity.precision
499 ),
500 });
501 }
502
503 if !raw_scales_match(quantity.precision, increment.precision) {
504 return Err(CorrectnessError::PredicateViolation {
505 message: format!(
506 "`quantity` raw scale does not match size increment precision, quantity precision was {}, size increment precision was {}",
507 quantity.precision, increment.precision
508 ),
509 });
510 }
511
512 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
513 let scale = QuantityRaw::pow(10, u32::from(precision_diff));
514
515 if !quantity.raw.is_multiple_of(scale) {
516 return Err(CorrectnessError::PredicateViolation {
517 message: format!(
518 "`quantity` requires rounding to instrument size precision {precision}, was {quantity}"
519 ),
520 });
521 }
522
523 if increment.raw != 0 && !quantity.raw.is_multiple_of(increment.raw) {
524 return Err(CorrectnessError::PredicateViolation {
525 message: format!(
526 "`quantity` is not aligned to size increment {increment}, was {quantity}"
527 ),
528 });
529 }
530
531 Quantity::from_raw_checked(quantity.raw, precision)
532 }
533
534 fn try_calculate_base_quantity(
539 &self,
540 quantity: Quantity,
541 last_price: Price,
542 ) -> anyhow::Result<Quantity> {
543 let last_px = last_price.as_decimal();
544 if last_px.is_zero() {
545 anyhow::bail!("`last_price` was zero when calculating base quantity");
546 }
547 let precision = u32::from(self.min_size_increment_precision());
548 let value = (quantity.as_decimal() / last_px)
549 .round_dp_with_strategy(precision, RoundingStrategy::MidpointNearestEven);
550 Quantity::from_decimal_dp(value, self.size_precision()).map_err(Into::into)
551 }
552
553 fn calculate_base_quantity(&self, quantity: Quantity, last_price: Price) -> Quantity {
558 self.try_calculate_base_quantity(quantity, last_price)
559 .unwrap()
560 }
561
562 #[inline(always)]
569 fn try_calculate_notional_value(
570 &self,
571 quantity: Quantity,
572 price: Price,
573 use_quote_for_inverse: Option<bool>,
574 ) -> anyhow::Result<Money> {
575 let use_quote_inverse = use_quote_for_inverse.unwrap_or(false);
576 let currency = if self.is_inverse() {
577 if use_quote_inverse {
578 self.quote_currency()
579 } else {
580 self.base_currency().ok_or_else(|| {
581 anyhow::anyhow!("inverse instrument {} has no base currency", self.id())
582 })?
583 }
584 } else if self.is_quanto() {
585 self.settlement_currency()
586 } else {
587 self.quote_currency()
588 };
589
590 try_notional_value(
591 quantity,
592 price,
593 self.multiplier(),
594 self.is_inverse(),
595 use_quote_inverse,
596 currency,
597 )
598 }
599
600 #[inline(always)]
604 fn calculate_notional_value(
605 &self,
606 quantity: Quantity,
607 price: Price,
608 use_quote_for_inverse: Option<bool>,
609 ) -> Money {
610 self.try_calculate_notional_value(quantity, price, use_quote_for_inverse)
611 .expect("invalid notional value")
612 }
613
614 #[inline(always)]
615 fn next_bid_price(&self, value: f64, n: i32) -> Option<Price> {
616 if n < 0 {
617 return None;
618 }
619
620 let price = if let Some(scheme) = self.tick_scheme_rule() {
621 scheme.next_bid_price(value, n, self.price_precision())?
622 } else {
623 let value = Decimal::from_str(&value.to_string()).ok()?;
624 let increment = self.price_increment().as_decimal();
625 if increment.is_zero() {
626 return None;
627 }
628 let base = (value / increment).floor() * increment;
629 let result = base - Decimal::from(n) * increment;
630 Price::from_decimal_dp(result, self.price_precision()).ok()?
631 };
632
633 if self.min_price().is_some_and(|min| price < min)
634 || self.max_price().is_some_and(|max| price > max)
635 {
636 return None;
637 }
638
639 Some(price)
640 }
641
642 #[inline(always)]
643 fn next_ask_price(&self, value: f64, n: i32) -> Option<Price> {
644 if n < 0 {
645 return None;
646 }
647
648 let price = if let Some(scheme) = self.tick_scheme_rule() {
649 scheme.next_ask_price(value, n, self.price_precision())?
650 } else {
651 let value = Decimal::from_str(&value.to_string()).ok()?;
652 let increment = self.price_increment().as_decimal();
653 if increment.is_zero() {
654 return None;
655 }
656 let base = (value / increment).ceil() * increment;
657 let result = base + Decimal::from(n) * increment;
658 Price::from_decimal_dp(result, self.price_precision()).ok()?
659 };
660
661 if self.min_price().is_some_and(|min| price < min)
662 || self.max_price().is_some_and(|max| price > max)
663 {
664 return None;
665 }
666
667 Some(price)
668 }
669
670 #[inline]
671 fn next_bid_prices(&self, value: f64, n: usize) -> Vec<Price> {
672 let mut prices = Vec::with_capacity(n);
673
674 for i in 0..n {
675 let Ok(i) = i32::try_from(i) else { break };
676 if let Some(price) = self.next_bid_price(value, i) {
677 prices.push(price);
678 } else {
679 break;
680 }
681 }
682
683 prices
684 }
685
686 #[inline]
687 fn next_ask_prices(&self, value: f64, n: usize) -> Vec<Price> {
688 let mut prices = Vec::with_capacity(n);
689
690 for i in 0..n {
691 let Ok(i) = i32::try_from(i) else { break };
692 if let Some(price) = self.next_ask_price(value, i) {
693 prices.push(price);
694 } else {
695 break;
696 }
697 }
698
699 prices
700 }
701}
702
703pub(crate) fn try_notional_value(
704 quantity: Quantity,
705 price: Price,
706 multiplier: Quantity,
707 is_inverse: bool,
708 use_quote_for_inverse: bool,
709 currency: Currency,
710) -> anyhow::Result<Money> {
711 let amount = if is_inverse && !use_quote_for_inverse {
712 anyhow::ensure!(
713 price.is_positive(),
714 "price must be positive for inverse notional valuation"
715 );
716 quantity
717 .as_decimal()
718 .checked_mul(multiplier.as_decimal())
719 .and_then(|value| value.checked_div(price.as_decimal()))
720 .ok_or_else(|| anyhow::anyhow!("inverse notional calculation overflow"))?
721 } else if is_inverse {
722 quantity.as_decimal()
723 } else {
724 quantity
725 .as_decimal()
726 .checked_mul(multiplier.as_decimal())
727 .and_then(|value| value.checked_mul(price.as_decimal()))
728 .ok_or_else(|| anyhow::anyhow!("notional calculation overflow"))?
729 };
730
731 Money::from_decimal(amount, currency).map_err(Into::into)
732}
733
734impl Display for CurrencyPair {
735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736 write!(
737 f,
738 "{}(instrument_id='{}', tick_scheme='{}', price_precision={}, size_precision={}, \
739price_increment={}, size_increment={}, multiplier={}, margin_init={}, margin_maint={})",
740 stringify!(CurrencyPair),
741 self.id,
742 self.tick_scheme()
743 .map_or_else(|| "None".into(), |s| s.to_string()),
744 self.price_precision(),
745 self.size_precision(),
746 self.price_increment(),
747 self.size_increment(),
748 self.multiplier(),
749 self.margin_init(),
750 self.margin_maint(),
751 )
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use nautilus_core::correctness::{CorrectnessResultExt, FAILED};
758 use proptest::prelude::*;
759 use rstest::rstest;
760 use rust_decimal::{Decimal, prelude::*};
761
762 use super::*;
763 use crate::{
764 instruments::stubs::*,
765 types::{ERROR_PRICE, Money, PRICE_ERROR, PRICE_UNDEF, QUANTITY_UNDEF},
766 };
767
768 pub(super) fn default_price_increment(precision: u8) -> Price {
769 let step = 10f64.powi(-i32::from(precision));
770 Price::new(step, precision)
771 }
772
773 #[rstest]
774 fn default_increment_precision() {
775 let inc = default_price_increment(2);
776 assert_eq!(inc, Price::new(0.01, 2));
777 }
778
779 #[rstest]
780 #[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) {
791 assert_eq!(
792 nautilus_core::string::parsing::min_increment_precision_from_str(&price.to_string()),
793 expected
794 );
795 }
796
797 #[rstest]
798 #[case(1.5, "1.500000")]
799 #[case(2.5, "2.500000")]
800 #[case(1.234_567_8, "1.234568")]
801 #[case(0.000_123, "0.000123")]
802 #[case(99_999.999_999, "99999.999999")]
803 fn make_qty_rounding(
804 currency_pair_btcusdt: CurrencyPair,
805 #[case] input: f64,
806 #[case] expected: &str,
807 ) {
808 assert_eq!(
809 currency_pair_btcusdt.make_qty(input, None).to_string(),
810 expected
811 );
812 }
813
814 #[rstest]
815 #[case(1.234_567_8, "1.234567")]
816 #[case(1.999_999_9, "1.999999")]
817 #[case(0.000_123_45, "0.000123")]
818 #[case(10.999_999_9, "10.999999")]
819 fn make_qty_round_down(
820 currency_pair_btcusdt: CurrencyPair,
821 #[case] input: f64,
822 #[case] expected: &str,
823 ) {
824 assert_eq!(
825 currency_pair_btcusdt
826 .make_qty(input, Some(true))
827 .to_string(),
828 expected
829 );
830 }
831
832 #[rstest]
833 #[case(1.234_567_8, "1.23457")]
834 #[case(2.345_678_1, "2.34568")]
835 #[case(0.00001, "0.00001")]
836 fn make_qty_precision(
837 currency_pair_ethusdt: CurrencyPair,
838 #[case] input: f64,
839 #[case] expected: &str,
840 ) {
841 assert_eq!(
842 currency_pair_ethusdt.make_qty(input, None).to_string(),
843 expected
844 );
845 }
846
847 #[rstest]
848 #[case(1.234_567_5, "1.234568")]
849 #[case(1.234_566_5, "1.234566")]
850 fn make_qty_half_even(
851 currency_pair_btcusdt: CurrencyPair,
852 #[case] input: f64,
853 #[case] expected: &str,
854 ) {
855 assert_eq!(
856 currency_pair_btcusdt.make_qty(input, None).to_string(),
857 expected
858 );
859 }
860
861 #[rstest]
862 #[case(dec!(1.5), None, dec!(1.5))]
863 #[case(dec!(1.2345678), None, dec!(1.234568))]
864 #[case(dec!(1.2345678), Some(true), dec!(1.234567))]
865 #[case(dec!(1.9999999), Some(true), dec!(1.999999))]
866 #[case(dec!(0.000123), None, dec!(0.000123))]
867 fn make_qty_from_decimal_matches_f64_path(
868 currency_pair_btcusdt: CurrencyPair,
869 #[case] value: Decimal,
870 #[case] round_down: Option<bool>,
871 #[case] expected: Decimal,
872 ) {
873 let from_decimal = currency_pair_btcusdt.make_qty_from_decimal(value, round_down);
874 let from_f64 =
875 currency_pair_btcusdt.make_qty(value.to_string().parse::<f64>().unwrap(), round_down);
876 assert_eq!(from_decimal, from_f64);
877 assert_eq!(from_decimal.as_decimal(), expected);
878 }
879
880 #[rstest]
881 #[should_panic(expected = "value rounded to zero")]
882 fn make_qty_from_decimal_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
883 currency_pair_btcusdt.make_qty_from_decimal(dec!(0.0000001), None);
884 }
885
886 #[rstest]
887 #[case(Price::from("10000"), "10000.00")]
888 #[case(Price::from("10000.0000"), "10000.00")]
889 fn try_normalize_price_rewrites_grid_aligned_values(
890 currency_pair_btcusdt: CurrencyPair,
891 #[case] input: Price,
892 #[case] expected: &str,
893 ) {
894 let normalized = currency_pair_btcusdt.try_normalize_price(input).unwrap();
895
896 assert_eq!(normalized.raw, input.raw);
897 assert_eq!(
898 normalized.precision,
899 currency_pair_btcusdt.price_precision()
900 );
901 assert_eq!(normalized, Price::from(expected));
902 }
903
904 #[rstest]
905 fn try_normalize_price_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
906 let error = currency_pair_btcusdt
907 .try_normalize_price(Price::from("10000.001"))
908 .unwrap_err();
909
910 assert!(matches!(
911 error,
912 CorrectnessError::PredicateViolation { ref message }
913 if message.contains("requires rounding to instrument price precision")
914 ));
915 }
916
917 #[rstest]
918 #[case(Price::from_raw(PRICE_UNDEF, 0), "PRICE_UNDEF")]
919 #[case(Price::from_raw(PRICE_ERROR, 0), "PRICE_ERROR")]
920 #[case(ERROR_PRICE, "ERROR_PRICE")]
921 fn try_normalize_price_rejects_sentinel_values(
922 currency_pair_btcusdt: CurrencyPair,
923 #[case] input: Price,
924 #[case] expected_value: &str,
925 ) {
926 let error = currency_pair_btcusdt
927 .try_normalize_price(input)
928 .unwrap_err();
929
930 match error {
931 CorrectnessError::InvalidValue {
932 param,
933 value,
934 type_name,
935 } => {
936 assert_eq!(param, "price");
937 assert_eq!(value, expected_value);
938 assert_eq!(type_name, "`Price`");
939 }
940 _ => panic!("expected invalid price error, was {error}"),
941 }
942 }
943
944 #[rstest]
945 #[case(Price::from("-10000"), Some(Price::from("-10000.00")))]
946 #[case(Price::from("-10000.001"), None)]
947 fn try_normalize_price_handles_negative_values(
948 currency_pair_btcusdt: CurrencyPair,
949 #[case] input: Price,
950 #[case] expected: Option<Price>,
951 ) {
952 let normalized = currency_pair_btcusdt.try_normalize_price(input).ok();
953
954 assert_eq!(normalized, expected);
955 }
956
957 #[rstest]
958 fn try_normalize_price_rejects_sub_increment_value() {
959 let instrument = CurrencyPair::new(
960 InstrumentId::from("TEST.VENUE"),
961 Symbol::from("TEST"),
962 Currency::from("BTC"),
963 Currency::from("USD"),
964 2,
965 2,
966 Price::from("0.50"),
967 Quantity::from("0.01"),
968 None,
969 None,
970 None,
971 None,
972 None,
973 None,
974 None,
975 None,
976 None,
977 None,
978 None,
979 None,
980 None,
981 None, UnixNanos::default(),
983 UnixNanos::default(),
984 );
985
986 assert_eq!(
987 instrument.try_normalize_price(Price::from("1.500")),
988 Ok(Price::from("1.50"))
989 );
990 let error = instrument
991 .try_normalize_price(Price::from("1.20"))
992 .unwrap_err();
993
994 assert!(matches!(
995 error,
996 CorrectnessError::PredicateViolation { ref message }
997 if message.contains("not aligned to price increment")
998 ));
999 }
1000
1001 #[rstest]
1002 #[case(Quantity::from("1"), "1.000000")]
1003 #[case(Quantity::from("1.0000000"), "1.000000")]
1004 fn try_normalize_qty_rewrites_grid_aligned_values(
1005 currency_pair_btcusdt: CurrencyPair,
1006 #[case] input: Quantity,
1007 #[case] expected: &str,
1008 ) {
1009 let normalized = currency_pair_btcusdt.try_normalize_qty(input).unwrap();
1010
1011 assert_eq!(normalized.raw, input.raw);
1012 assert_eq!(normalized.precision, currency_pair_btcusdt.size_precision());
1013 assert_eq!(normalized, Quantity::from(expected));
1014 }
1015
1016 #[rstest]
1017 fn try_normalize_qty_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
1018 let error = currency_pair_btcusdt
1019 .try_normalize_qty(Quantity::from("1.0000001"))
1020 .unwrap_err();
1021
1022 assert!(matches!(
1023 error,
1024 CorrectnessError::PredicateViolation { ref message }
1025 if message.contains("requires rounding to instrument size precision")
1026 ));
1027 }
1028
1029 #[rstest]
1030 fn try_normalize_qty_rejects_undefined_value(currency_pair_btcusdt: CurrencyPair) {
1031 let error = currency_pair_btcusdt
1032 .try_normalize_qty(Quantity::from_raw(QUANTITY_UNDEF, 0))
1033 .unwrap_err();
1034
1035 match error {
1036 CorrectnessError::InvalidValue {
1037 param,
1038 value,
1039 type_name,
1040 } => {
1041 assert_eq!(param, "quantity");
1042 assert_eq!(value, "QUANTITY_UNDEF");
1043 assert_eq!(type_name, "`Quantity`");
1044 }
1045 _ => panic!("expected invalid quantity error, was {error}"),
1046 }
1047 }
1048
1049 #[cfg(feature = "defi")]
1050 #[rstest]
1051 fn try_normalize_values_reject_mixed_raw_scales() {
1052 let defi_precision = 18;
1053 let price_increment = Price::from_raw(PriceRaw::from(5) * PriceRaw::pow(10, 17), 18);
1054 let size_increment =
1055 Quantity::from_raw(QuantityRaw::from(5_u8) * QuantityRaw::pow(10, 17), 18);
1056 let instrument = CurrencyPair::new(
1057 InstrumentId::from("TEST.VENUE"),
1058 Symbol::from("TEST"),
1059 Currency::from("BTC"),
1060 Currency::from("USD"),
1061 defi_precision,
1062 defi_precision,
1063 price_increment,
1064 size_increment,
1065 None,
1066 None,
1067 None,
1068 None,
1069 None,
1070 None,
1071 None,
1072 None,
1073 None,
1074 None,
1075 None,
1076 None,
1077 None,
1078 None, UnixNanos::default(),
1080 UnixNanos::default(),
1081 );
1082 let fixed_scale = u32::from(FIXED_PRECISION);
1083 let fixed_price = Price::from_raw(
1084 PriceRaw::pow(10, fixed_scale) * PriceRaw::from(100),
1085 FIXED_PRECISION,
1086 );
1087 let fixed_qty = Quantity::from_raw(
1088 QuantityRaw::pow(10, fixed_scale) * QuantityRaw::from(100_u8),
1089 FIXED_PRECISION,
1090 );
1091
1092 let price_error = instrument.try_normalize_price(fixed_price).unwrap_err();
1093 let qty_error = instrument.try_normalize_qty(fixed_qty).unwrap_err();
1094
1095 assert!(matches!(
1096 price_error,
1097 CorrectnessError::PredicateViolation { ref message }
1098 if message.contains("raw scale does not match instrument price precision")
1099 ));
1100 assert!(matches!(
1101 qty_error,
1102 CorrectnessError::PredicateViolation { ref message }
1103 if message.contains("raw scale does not match instrument size precision")
1104 ));
1105 }
1106
1107 #[rstest]
1108 fn try_normalize_qty_rejects_sub_increment_value() {
1109 let instrument = CurrencyPair::new(
1110 InstrumentId::from("TEST.VENUE"),
1111 Symbol::from("TEST"),
1112 Currency::from("BTC"),
1113 Currency::from("USD"),
1114 2,
1115 2,
1116 Price::from("0.01"),
1117 Quantity::from("0.50"),
1118 None,
1119 None,
1120 None,
1121 None,
1122 None,
1123 None,
1124 None,
1125 None,
1126 None,
1127 None,
1128 None,
1129 None,
1130 None,
1131 None, UnixNanos::default(),
1133 UnixNanos::default(),
1134 );
1135
1136 assert_eq!(
1137 instrument.try_normalize_qty(Quantity::from("1.500")),
1138 Ok(Quantity::from("1.50"))
1139 );
1140 let error = instrument
1141 .try_normalize_qty(Quantity::from("1.20"))
1142 .unwrap_err();
1143
1144 assert!(matches!(
1145 error,
1146 CorrectnessError::PredicateViolation { ref message }
1147 if message.contains("not aligned to size increment")
1148 ));
1149 }
1150
1151 #[rstest]
1152 #[should_panic(expected = "value rounded to zero")]
1153 fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
1154 currency_pair_btcusdt.make_qty(1e-12, None);
1155 }
1156
1157 #[rstest]
1158 fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
1159 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1160 let price = currency_pair_btcusdt.make_price(10_000.0);
1161 let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
1162 let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
1163 assert_eq!(notional, expected);
1164 }
1165
1166 #[rstest]
1167 fn currency_pair_is_not_quanto(currency_pair_btcusdt: CurrencyPair) {
1168 assert!(!currency_pair_btcusdt.is_quanto());
1169 assert_eq!(currency_pair_btcusdt.cost_currency(), Currency::USDT());
1170 }
1171
1172 #[rstest]
1173 fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
1174 let start = 10_000.123_4;
1175 let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
1176 let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
1177 assert!(bid_1 < bid_0);
1178 let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
1179 assert_eq!(asks.len(), 3);
1180 assert!(asks[0] > bid_0);
1181 }
1182
1183 #[rstest]
1184 fn tick_navigation_uses_tick_scheme() {
1185 let instrument = CurrencyPair::new(
1186 InstrumentId::from("TEST.VENUE"),
1187 Symbol::from("TEST"),
1188 Currency::from("BTC"),
1189 Currency::from("USD"),
1190 2,
1191 2,
1192 Price::new(0.01, 2),
1193 Quantity::from("0.01"),
1194 None,
1195 None,
1196 None,
1197 None,
1198 None,
1199 None,
1200 None,
1201 None,
1202 None,
1203 None,
1204 None,
1205 None,
1206 Some(Ustr::from("FIXED_PRECISION_1")),
1207 None,
1208 UnixNanos::default(),
1209 UnixNanos::default(),
1210 );
1211
1212 assert_eq!(
1213 instrument.tick_scheme(),
1214 Some(Ustr::from("FIXED_PRECISION_1"))
1215 );
1216 assert_eq!(instrument.next_bid_price(1.23, 0), Some(Price::new(1.2, 2)));
1217 assert_eq!(instrument.next_ask_price(1.23, 0), Some(Price::new(1.3, 2)));
1218 }
1219
1220 #[rstest]
1221 #[case("BOGUS")]
1222 #[case("FIXED_PRECISION_99")]
1223 fn invalid_tick_scheme_returns_error(#[case] tick_scheme: &str) {
1224 let err = CurrencyPair::new_checked(
1225 InstrumentId::from("TEST.VENUE"),
1226 Symbol::from("TEST"),
1227 Currency::from("BTC"),
1228 Currency::from("USD"),
1229 2,
1230 2,
1231 Price::new(0.01, 2),
1232 Quantity::from("0.01"),
1233 None,
1234 None,
1235 None,
1236 None,
1237 None,
1238 None,
1239 None,
1240 None,
1241 None,
1242 None,
1243 None,
1244 None,
1245 Some(Ustr::from(tick_scheme)),
1246 None,
1247 UnixNanos::default(),
1248 UnixNanos::default(),
1249 )
1250 .expect_err("invalid tick scheme must fail");
1251
1252 assert!(
1253 err.to_string()
1254 .contains("tick_scheme not found in tick schemes"),
1255 "{err}"
1256 );
1257 }
1258
1259 #[rstest]
1260 #[should_panic(expected = "'margin_init' not positive")]
1261 fn validate_negative_margin_init() {
1262 let size_increment = Quantity::new(0.01, 2);
1263 let multiplier = Quantity::new(1.0, 0);
1264
1265 validate_instrument_common(
1266 2,
1267 2, size_increment, multiplier, dec!(-0.01), dec!(0.01), None, None, None, None, None, None, None, None, )
1281 .expect_display(FAILED);
1282 }
1283
1284 #[rstest]
1285 #[should_panic(expected = "'margin_maint' not positive")]
1286 fn validate_negative_margin_maint() {
1287 let size_increment = Quantity::new(0.01, 2);
1288 let multiplier = Quantity::new(1.0, 0);
1289
1290 validate_instrument_common(
1291 2,
1292 2, size_increment, multiplier, dec!(0.01), dec!(-0.01), None, None, None, None, None, None, None, None, )
1306 .expect_display(FAILED);
1307 }
1308
1309 #[rstest]
1310 #[should_panic(expected = "'margin_init' not positive")]
1311 fn validate_negative_max_qty() {
1312 let quantity = Quantity::new(0.0, 0);
1313 validate_instrument_common(
1314 2,
1315 2,
1316 Quantity::new(0.01, 2),
1317 Quantity::new(1.0, 0),
1318 dec!(0),
1319 dec!(0),
1320 None,
1321 None,
1322 Some(quantity),
1323 None,
1324 None,
1325 None,
1326 None,
1327 None,
1328 )
1329 .expect_display(FAILED);
1330 }
1331
1332 #[rstest]
1333 fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
1334 let price = currency_pair_ethusdt.make_price(-123.456_789);
1335 assert!(price.as_f64() < 0.0);
1336 }
1337
1338 #[rstest]
1339 fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
1340 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1341 let price = currency_pair_btcusdt.make_price(10_000.0);
1342 let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
1343 assert_eq!(base.to_string(), "0.000200");
1344 }
1345
1346 #[rstest]
1347 fn base_quantity_zero_last_price_returns_error(currency_pair_btcusdt: CurrencyPair) {
1348 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1349 let error = currency_pair_btcusdt
1350 .try_calculate_base_quantity(quantity, Price::new(0.0, 2))
1351 .unwrap_err();
1352 assert!(
1353 error.to_string().contains("`last_price` was zero"),
1354 "{error}"
1355 );
1356 }
1357
1358 #[rstest]
1359 #[case(f64::NAN)]
1360 #[case(f64::INFINITY)]
1361 #[case(1e30)] fn make_price_invalid_value_returns_error(
1363 currency_pair_btcusdt: CurrencyPair,
1364 #[case] value: f64,
1365 ) {
1366 let error = currency_pair_btcusdt.try_make_price(value).unwrap_err();
1367 assert!(
1368 error.to_string().contains("invalid `value` for make_price"),
1369 "{error}"
1370 );
1371 }
1372
1373 #[rstest]
1374 fn make_qty_invalid_value_returns_error(currency_pair_btcusdt: CurrencyPair) {
1375 let error = currency_pair_btcusdt
1376 .try_make_qty(f64::NAN, None)
1377 .unwrap_err();
1378 assert!(
1379 error.to_string().contains("invalid `value` for make_qty"),
1380 "{error}"
1381 );
1382 }
1383
1384 #[rstest]
1385 fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1386 let start = 10_000.0;
1387 let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
1388 assert_eq!(bids.len(), 5);
1389 for i in 1..bids.len() {
1390 assert!(bids[i] < bids[i - 1]);
1391 }
1392 }
1393
1394 #[rstest]
1395 fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1396 let start = 10_000.0;
1397 let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
1398 assert_eq!(asks.len(), 5);
1399 for i in 1..asks.len() {
1400 assert!(asks[i] > asks[i - 1]);
1401 }
1402 }
1403
1404 #[rstest]
1405 #[should_panic(expected = "'margin_init' not positive")]
1406 fn validate_price_increment_precision_mismatch() {
1407 let size_increment = Quantity::new(0.01, 2);
1408 let multiplier = Quantity::new(1.0, 0);
1409 let price_increment = Price::new(0.001, 3);
1410 validate_instrument_common(
1411 2,
1412 2,
1413 size_increment,
1414 multiplier,
1415 dec!(0),
1416 dec!(0),
1417 Some(price_increment),
1418 None,
1419 None,
1420 None,
1421 None,
1422 None,
1423 None,
1424 None,
1425 )
1426 .expect_display(FAILED);
1427 }
1428
1429 #[rstest]
1430 #[should_panic(expected = "'margin_init' not positive")]
1431 fn validate_min_price_exceeds_max_price() {
1432 let size_increment = Quantity::new(0.01, 2);
1433 let multiplier = Quantity::new(1.0, 0);
1434 let min_price = Price::new(10.0, 2);
1435 let max_price = Price::new(5.0, 2);
1436 validate_instrument_common(
1437 2,
1438 2,
1439 size_increment,
1440 multiplier,
1441 dec!(0),
1442 dec!(0),
1443 None,
1444 None,
1445 None,
1446 None,
1447 None,
1448 None,
1449 Some(max_price),
1450 Some(min_price),
1451 )
1452 .expect_display(FAILED);
1453 }
1454
1455 #[rstest]
1456 fn validate_instrument_common_ok() {
1457 let res = validate_instrument_common(
1458 2,
1459 4,
1460 Quantity::new(0.0001, 4),
1461 Quantity::new(1.0, 0),
1462 dec!(0.02),
1463 dec!(0.01),
1464 Some(Price::new(0.01, 2)),
1465 None,
1466 None,
1467 None,
1468 None,
1469 None,
1470 None,
1471 None,
1472 );
1473 assert!(matches!(res, Ok(())));
1474 }
1475
1476 #[rstest]
1477 #[should_panic(expected = "not in range")]
1478 fn validate_multiple_errors() {
1479 validate_instrument_common(
1480 2,
1481 2,
1482 Quantity::new(-0.01, 2),
1483 Quantity::new(0.0, 0),
1484 dec!(0),
1485 dec!(0),
1486 None,
1487 None,
1488 None,
1489 None,
1490 None,
1491 None,
1492 None,
1493 None,
1494 )
1495 .expect_display(FAILED);
1496 }
1497
1498 #[rstest]
1499 #[case(1.234_999_9, false, "1.235000")]
1500 #[case(1.234_999_9, true, "1.234999")]
1501 fn make_qty_boundary(
1502 currency_pair_btcusdt: CurrencyPair,
1503 #[case] input: f64,
1504 #[case] round_down: bool,
1505 #[case] expected: &str,
1506 ) {
1507 let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
1508 assert_eq!(quantity.to_string(), expected);
1509 }
1510
1511 #[rstest]
1512 #[case(1.234_999, 1.23)]
1513 #[case(1.235, 1.24)]
1514 #[case(1.235_001, 1.24)]
1515 fn make_price_rounding_parity(
1516 currency_pair_btcusdt: CurrencyPair,
1517 #[case] input: f64,
1518 #[case] expected: f64,
1519 ) {
1520 let price = currency_pair_btcusdt.make_price(input);
1521 assert!((price.as_f64() - expected).abs() < 1e-9);
1522 }
1523
1524 #[rstest]
1525 fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
1526 let rounding_precision = std::cmp::min(
1527 currency_pair_btcusdt.price_precision(),
1528 currency_pair_btcusdt.min_price_increment_precision(),
1529 );
1530 let step = 10f64.powi(-i32::from(rounding_precision));
1531 let base_even_multiple = 42.0;
1532 let base_value = step * base_even_multiple;
1533 let delta = step / 2000.0;
1534 let value_below = base_value + 0.5 * step - delta;
1535 let value_exact = base_value + 0.5 * step;
1536 let value_above = base_value + 0.5 * step + delta;
1537 let price_below = currency_pair_btcusdt.make_price(value_below);
1538 let price_exact = currency_pair_btcusdt.make_price(value_exact);
1539 let price_above = currency_pair_btcusdt.make_price(value_above);
1540 assert_eq!(price_below, price_exact);
1541 assert_ne!(price_exact, price_above);
1542 }
1543
1544 #[rstest]
1545 #[case(dec!(1.234999), dec!(1.23))]
1546 #[case(dec!(1.235), dec!(1.24))]
1547 #[case(dec!(1.235001), dec!(1.24))]
1548 #[case(dec!(10000.0), dec!(10000.0))]
1549 fn make_price_from_decimal_matches_f64_path(
1550 currency_pair_btcusdt: CurrencyPair,
1551 #[case] value: Decimal,
1552 #[case] expected: Decimal,
1553 ) {
1554 let from_decimal = currency_pair_btcusdt.make_price_from_decimal(value);
1555 let from_f64 = currency_pair_btcusdt.make_price(value.to_string().parse::<f64>().unwrap());
1556 assert_eq!(from_decimal, from_f64);
1557 assert_eq!(from_decimal.as_decimal(), expected);
1558 }
1559
1560 #[rstest]
1561 fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
1562 assert!(ethbtc_quanto.is_quanto());
1563 }
1564
1565 #[rstest]
1566 fn notional_quanto(ethbtc_quanto: CryptoFuture) {
1567 let quantity = ethbtc_quanto.make_qty(5.0, None);
1568 let price = ethbtc_quanto.make_price(0.036);
1569 let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
1570 let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
1571 assert_eq!(notional, expected);
1572 }
1573
1574 #[rstest]
1575 #[case("USD", "BUSD")]
1576 #[case("USD", "FDUSD")]
1577 #[case("USD", "pUSD")]
1578 #[case("USD", "TUSD")]
1579 #[case("USD", "USD")]
1580 #[case("USD", "USDC")]
1581 #[case("USD", "USDC.e")]
1582 #[case("USD", "USDP")]
1583 #[case("USD", "USDT")]
1584 #[case("BUSD", "USD")]
1585 #[case("FDUSD", "USD")]
1586 #[case("pUSD", "USD")]
1587 #[case("TUSD", "USD")]
1588 #[case("USDC", "USD")]
1589 #[case("USDC.e", "USD")]
1590 #[case("USDP", "USD")]
1591 #[case("USDT", "USD")]
1592 fn usd_equivalent_settlement_is_not_quanto(
1593 #[case] quote_currency_code: &str,
1594 #[case] settlement_currency_code: &str,
1595 ) {
1596 let quote_currency =
1597 Currency::try_from_str(quote_currency_code).expect("quote currency must exist");
1598 let settlement_currency = Currency::try_from_str(settlement_currency_code)
1599 .expect("settlement currency must exist");
1600 let instrument = crypto_future_with_quote_settlement(quote_currency, settlement_currency);
1601 let quantity = instrument.make_qty(5.0, None);
1602 let price = instrument.make_price(1000.0);
1603 let notional = instrument.calculate_notional_value(quantity, price, None);
1604
1605 assert!(!instrument.is_quanto());
1606 assert_eq!(instrument.cost_currency(), quote_currency);
1607 assert_eq!(notional, Money::new(5000.0, quote_currency));
1608 }
1609
1610 #[rstest]
1611 fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
1612 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1613 let price = xbtusd_inverse_perp.make_price(50_000.0);
1614 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
1615 let expected = Money::new(
1616 100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
1617 xbtusd_inverse_perp.base_currency().unwrap(),
1618 );
1619 assert_eq!(notional, expected);
1620 }
1621
1622 #[rstest]
1623 fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
1624 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1625 let price = xbtusd_inverse_perp.make_price(50_000.0);
1626 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
1627 let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
1628 assert_eq!(notional, expected);
1629 }
1630
1631 #[rstest]
1632 fn try_notional_inverse_zero_price_returns_error(xbtusd_inverse_perp: CryptoPerpetual) {
1633 let result = xbtusd_inverse_perp.try_calculate_notional_value(
1634 xbtusd_inverse_perp.make_qty(100.0, None),
1635 Price::new(0.0, 1),
1636 Some(false),
1637 );
1638
1639 assert_eq!(
1640 result.unwrap_err().to_string(),
1641 "price must be positive for inverse notional valuation"
1642 );
1643 }
1644
1645 #[rstest]
1646 fn try_notional_unrepresentable_money_returns_error(currency_pair_btcusdt: CurrencyPair) {
1647 let result = currency_pair_btcusdt.try_calculate_notional_value(
1648 Quantity::from("100000000"),
1649 Price::from("100000000"),
1650 None,
1651 );
1652
1653 assert!(result.is_err());
1654 }
1655
1656 #[rstest]
1657 fn try_notional_decimal_overflow_returns_error() {
1658 let result = try_notional_value(
1659 Quantity::from("9000000000"),
1660 Price::from("9000000000"),
1661 Quantity::from("9000000000"),
1662 false,
1663 false,
1664 Currency::USD(),
1665 );
1666
1667 assert_eq!(
1668 result.unwrap_err().to_string(),
1669 "notional calculation overflow"
1670 );
1671 }
1672
1673 #[rstest]
1674 #[should_panic(expected = "'margin_init' not positive")]
1675 fn validate_non_positive_max_price() {
1676 let size_increment = Quantity::new(0.01, 2);
1677 let multiplier = Quantity::new(1.0, 0);
1678 let max_price = Price::new(0.0, 2);
1679 validate_instrument_common(
1680 2,
1681 2,
1682 size_increment,
1683 multiplier,
1684 dec!(0),
1685 dec!(0),
1686 None,
1687 None,
1688 None,
1689 None,
1690 None,
1691 None,
1692 Some(max_price),
1693 None,
1694 )
1695 .expect_display(FAILED);
1696 }
1697
1698 #[rstest]
1699 #[should_panic(expected = "'margin_init' not positive")]
1700 fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1701 let size_increment = Quantity::new(0.01, 2);
1702 let multiplier = Quantity::new(1.0, 0);
1703 let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1704 validate_instrument_common(
1705 2,
1706 2,
1707 size_increment,
1708 multiplier,
1709 dec!(0),
1710 dec!(0),
1711 None,
1712 None,
1713 None,
1714 None,
1715 Some(max_notional),
1716 None,
1717 None,
1718 None,
1719 )
1720 .expect_display(FAILED);
1721 }
1722
1723 #[rstest]
1724 #[should_panic(expected = "'margin_init' not positive")]
1725 fn validate_price_increment_min_price_precision_mismatch() {
1726 let size_increment = Quantity::new(0.01, 2);
1727 let multiplier = Quantity::new(1.0, 0);
1728 let price_increment = Price::new(0.01, 2);
1729 let min_price = Price::new(1.0, 3);
1730 validate_instrument_common(
1731 2,
1732 2,
1733 size_increment,
1734 multiplier,
1735 dec!(0),
1736 dec!(0),
1737 Some(price_increment),
1738 None,
1739 None,
1740 None,
1741 None,
1742 None,
1743 None,
1744 Some(min_price),
1745 )
1746 .expect_display(FAILED);
1747 }
1748
1749 #[rstest]
1750 #[should_panic(expected = "'margin_init' not positive")]
1751 fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1752 let size_increment = Quantity::new(0.01, 2);
1753 let multiplier = Quantity::new(1.0, 0);
1754 let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1755 let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1756 validate_instrument_common(
1757 2,
1758 2,
1759 size_increment,
1760 multiplier,
1761 dec!(0),
1762 dec!(0),
1763 None,
1764 None,
1765 None,
1766 None,
1767 Some(max_notional),
1768 Some(min_notional),
1769 None,
1770 None,
1771 )
1772 .expect_display(FAILED);
1773 }
1774
1775 #[rstest]
1776 #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1777 #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1778 #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1779 #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1780 #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1781 #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1782 #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1783 #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1784 #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1785 fn base_qty_rounding(
1786 currency_pair_btcusdt: CurrencyPair,
1787 #[case] q: Decimal,
1788 #[case] px: Decimal,
1789 #[case] expected: f64,
1790 ) {
1791 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1792 let price = Price::new(px.to_f64().unwrap(), 8);
1793 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1794 assert!((base.as_f64() - expected).abs() < 1e-9);
1795 }
1796
1797 proptest! {
1798 #[rstest]
1799 fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1800 let instrument = currency_pair_btcusdt();
1801 let price = instrument.make_price(input);
1802 prop_assert!(price.as_f64().is_finite());
1803 let quantity = instrument.make_qty(input, None);
1804 prop_assert!(quantity.as_f64().is_finite());
1805 }
1806 }
1807
1808 #[rstest]
1809 fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1810 if let Some(max_price) = currency_pair_btcusdt.max_price() {
1811 assert!(
1812 currency_pair_btcusdt
1813 .next_ask_price(max_price.as_f64(), 1)
1814 .is_none()
1815 );
1816 }
1817 }
1818
1819 #[rstest]
1820 fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1821 if let Some(max_price) = currency_pair_ethusdt.max_price() {
1822 assert!(
1823 currency_pair_ethusdt
1824 .next_ask_price(max_price.as_f64(), 1)
1825 .is_none()
1826 );
1827 }
1828 }
1829
1830 #[rstest]
1831 fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1832 if let Some(min_price) = currency_pair_btcusdt.min_price() {
1833 assert!(
1834 currency_pair_btcusdt
1835 .next_bid_price(min_price.as_f64(), 1)
1836 .is_none()
1837 );
1838 }
1839 }
1840
1841 #[rstest]
1842 fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1843 if let Some(min_price) = currency_pair_ethusdt.min_price() {
1844 assert!(
1845 currency_pair_ethusdt
1846 .next_bid_price(min_price.as_f64(), 1)
1847 .is_none()
1848 );
1849 }
1850 }
1851
1852 #[rstest]
1853 fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1854 if let Some(max_price) = ethbtc_quanto.max_price() {
1855 assert!(
1856 ethbtc_quanto
1857 .next_ask_price(max_price.as_f64(), 1)
1858 .is_none()
1859 );
1860 }
1861 }
1862
1863 #[rstest]
1864 #[case(0.999_999, false)]
1865 #[case(0.999_999, true)]
1866 #[case(1.000_000_1, false)]
1867 #[case(1.000_000_1, true)]
1868 #[case(1.234_5, false)]
1869 #[case(1.234_5, true)]
1870 #[case(2.345_5, false)]
1871 #[case(2.345_5, true)]
1872 #[case(0.000_999_999, false)]
1873 #[case(0.000_999_999, true)]
1874 fn quantity_rounding_grid(
1875 currency_pair_btcusdt: CurrencyPair,
1876 #[case] input: f64,
1877 #[case] round_down: bool,
1878 ) {
1879 let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1880 assert!(qty.as_f64().is_finite());
1881 }
1882
1883 #[rstest]
1884 fn pyo3_failure_validate_price_increment_max_price_precision_mismatch() {
1885 let size_increment = Quantity::new(0.01, 2);
1886 let multiplier = Quantity::new(1.0, 0);
1887 let price_increment = Price::new(0.01, 2);
1888 let max_price = Price::new(1.0, 3);
1889 let res = validate_instrument_common(
1890 2,
1891 2,
1892 size_increment,
1893 multiplier,
1894 dec!(0),
1895 dec!(0),
1896 Some(price_increment),
1897 None,
1898 None,
1899 None,
1900 None,
1901 None,
1902 Some(max_price),
1903 None,
1904 );
1905 assert!(res.is_err());
1906 }
1907
1908 #[rstest]
1909 #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1910 #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1911 #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1912 #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1913 #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1914 #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1915 #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1916 #[case::dp16(
1917 Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1918 Decimal::new(2, 0),
1919 500.0
1920 )]
1921 #[case::dp17(
1922 Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1923 Decimal::new(2, 0),
1924 500.0
1925 )]
1926 fn base_qty_rounding_high_dp(
1927 currency_pair_btcusdt: CurrencyPair,
1928 #[case] q: Decimal,
1929 #[case] px: Decimal,
1930 #[case] expected: f64,
1931 ) {
1932 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1933 let price = Price::new(px.to_f64().unwrap(), 8);
1934 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1935 assert!((base.as_f64() - expected).abs() < 1e-9);
1936 }
1937
1938 #[rstest]
1939 fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1940 let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1941 assert!(check_positive_money(money, "money").is_ok());
1942 }
1943
1944 #[rstest]
1945 #[should_panic(expected = "NotPositive")]
1946 fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1947 let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1948 check_positive_money(money, "money").unwrap();
1949 }
1950
1951 #[rstest]
1952 #[should_panic(expected = "NotPositive")]
1953 fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1954 let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1955 check_positive_money(money, "money").unwrap();
1956 }
1957
1958 fn crypto_future_with_quote_settlement(
1959 quote_currency: Currency,
1960 settlement_currency: Currency,
1961 ) -> CryptoFuture {
1962 CryptoFuture::new(
1963 InstrumentId::from("ETHUSD-QUANTO-TEST.BINANCE"),
1964 Symbol::from("ETHUSD-QUANTO-TEST"),
1965 Currency::ETH(),
1966 quote_currency,
1967 settlement_currency,
1968 false,
1969 0.into(),
1970 0.into(),
1971 2,
1972 0,
1973 Price::from("0.01"),
1974 Quantity::from("1"),
1975 None,
1976 None,
1977 None,
1978 None,
1979 None,
1980 None,
1981 None,
1982 None,
1983 None,
1984 None,
1985 None,
1986 None,
1987 None,
1988 None,
1989 0.into(),
1990 0.into(),
1991 )
1992 }
1993
1994 #[rstest]
1995 fn make_price_with_trailing_zeros_in_increment() {
1996 let instrument = CurrencyPair::new(
1999 InstrumentId::from("TEST.VENUE"),
2000 Symbol::from("TEST"),
2001 Currency::from("BTC"),
2002 Currency::from("USD"),
2003 2, 2, Price::new(0.50, 2), Quantity::from("0.01"),
2007 None,
2008 None,
2009 None,
2010 None,
2011 None,
2012 None,
2013 None,
2014 None,
2015 None,
2016 None,
2017 None,
2018 None,
2019 None,
2020 None, UnixNanos::default(),
2022 UnixNanos::default(),
2023 );
2024
2025 assert_eq!(instrument.min_price_increment_precision(), 1);
2027
2028 let price = instrument.make_price(1.234);
2031 assert_eq!(price.as_f64(), 1.2);
2032
2033 let price = instrument.make_price(1.25);
2035 assert_eq!(price.as_f64(), 1.2);
2036
2037 let price = instrument.make_price(1.35);
2039 assert_eq!(price.as_f64(), 1.4);
2040
2041 assert_eq!(price.precision, 2);
2043 }
2044
2045 #[rstest]
2046 fn make_qty_with_trailing_zeros_in_increment() {
2047 let instrument = CurrencyPair::new(
2049 InstrumentId::from("TEST.VENUE"),
2050 Symbol::from("TEST"),
2051 Currency::from("BTC"),
2052 Currency::from("USD"),
2053 2, 2, Price::new(0.01, 2),
2056 Quantity::new(0.50, 2), None,
2058 None,
2059 None,
2060 None,
2061 None,
2062 None,
2063 None,
2064 None,
2065 None,
2066 None,
2067 None,
2068 None,
2069 None,
2070 None, UnixNanos::default(),
2072 UnixNanos::default(),
2073 );
2074
2075 assert_eq!(instrument.min_size_increment_precision(), 1);
2077
2078 let qty = instrument.make_qty(1.234, None);
2081 assert_eq!(qty.as_f64(), 1.2);
2082
2083 let qty = instrument.make_qty(1.25, None);
2085 assert_eq!(qty.as_f64(), 1.2);
2086
2087 let qty = instrument.make_qty(1.35, None);
2089 assert_eq!(qty.as_f64(), 1.4);
2090
2091 assert_eq!(qty.precision, 2);
2093
2094 let qty = instrument.make_qty(1.99, Some(true));
2096 assert_eq!(qty.as_f64(), 1.9);
2097 }
2098
2099 #[rstest]
2100 #[case(InstrumentClass::Future, true)]
2101 #[case(InstrumentClass::FuturesSpread, true)]
2102 #[case(InstrumentClass::Option, true)]
2103 #[case(InstrumentClass::OptionSpread, true)]
2104 #[case(InstrumentClass::Spot, false)]
2105 #[case(InstrumentClass::Swap, false)]
2106 #[case(InstrumentClass::Forward, false)]
2107 #[case(InstrumentClass::Cfd, false)]
2108 #[case(InstrumentClass::Bond, false)]
2109 #[case(InstrumentClass::Warrant, false)]
2110 #[case(InstrumentClass::SportsBetting, false)]
2111 #[case(InstrumentClass::BinaryOption, false)]
2112 fn test_instrument_class_has_expiration(
2113 #[case] instrument_class: InstrumentClass,
2114 #[case] expected: bool,
2115 ) {
2116 assert_eq!(instrument_class.has_expiration(), expected);
2117 }
2118}