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 = "test-support"))]
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::builder()
960 .instrument_id(InstrumentId::from("TEST.VENUE"))
961 .raw_symbol(Symbol::from("TEST"))
962 .base_currency(Currency::from("BTC"))
963 .quote_currency(Currency::from("USD"))
964 .price_precision(2)
965 .size_precision(2)
966 .price_increment(Price::from("0.50"))
967 .size_increment(Quantity::from("0.01"))
968 .ts_event(UnixNanos::default())
969 .ts_init(UnixNanos::default())
970 .build()
971 .unwrap();
972
973 assert_eq!(
974 instrument.try_normalize_price(Price::from("1.500")),
975 Ok(Price::from("1.50"))
976 );
977 let error = instrument
978 .try_normalize_price(Price::from("1.20"))
979 .unwrap_err();
980
981 assert!(matches!(
982 error,
983 CorrectnessError::PredicateViolation { ref message }
984 if message.contains("not aligned to price increment")
985 ));
986 }
987
988 #[rstest]
989 #[case(Quantity::from("1"), "1.000000")]
990 #[case(Quantity::from("1.0000000"), "1.000000")]
991 fn try_normalize_qty_rewrites_grid_aligned_values(
992 currency_pair_btcusdt: CurrencyPair,
993 #[case] input: Quantity,
994 #[case] expected: &str,
995 ) {
996 let normalized = currency_pair_btcusdt.try_normalize_qty(input).unwrap();
997
998 assert_eq!(normalized.raw, input.raw);
999 assert_eq!(normalized.precision, currency_pair_btcusdt.size_precision());
1000 assert_eq!(normalized, Quantity::from(expected));
1001 }
1002
1003 #[rstest]
1004 fn try_normalize_qty_rejects_sub_precision_value(currency_pair_btcusdt: CurrencyPair) {
1005 let error = currency_pair_btcusdt
1006 .try_normalize_qty(Quantity::from("1.0000001"))
1007 .unwrap_err();
1008
1009 assert!(matches!(
1010 error,
1011 CorrectnessError::PredicateViolation { ref message }
1012 if message.contains("requires rounding to instrument size precision")
1013 ));
1014 }
1015
1016 #[rstest]
1017 fn try_normalize_qty_rejects_undefined_value(currency_pair_btcusdt: CurrencyPair) {
1018 let error = currency_pair_btcusdt
1019 .try_normalize_qty(Quantity::from_raw(QUANTITY_UNDEF, 0))
1020 .unwrap_err();
1021
1022 match error {
1023 CorrectnessError::InvalidValue {
1024 param,
1025 value,
1026 type_name,
1027 } => {
1028 assert_eq!(param, "quantity");
1029 assert_eq!(value, "QUANTITY_UNDEF");
1030 assert_eq!(type_name, "`Quantity`");
1031 }
1032 _ => panic!("expected invalid quantity error, was {error}"),
1033 }
1034 }
1035
1036 #[cfg(feature = "defi")]
1037 #[rstest]
1038 fn try_normalize_values_reject_mixed_raw_scales() {
1039 let defi_precision = 18;
1040 let price_increment = Price::from_raw(PriceRaw::from(5) * PriceRaw::pow(10, 17), 18);
1041 let size_increment =
1042 Quantity::from_raw(QuantityRaw::from(5_u8) * QuantityRaw::pow(10, 17), 18);
1043 let instrument = CurrencyPair::builder()
1044 .instrument_id(InstrumentId::from("TEST.VENUE"))
1045 .raw_symbol(Symbol::from("TEST"))
1046 .base_currency(Currency::from("BTC"))
1047 .quote_currency(Currency::from("USD"))
1048 .price_precision(defi_precision)
1049 .size_precision(defi_precision)
1050 .price_increment(price_increment)
1051 .size_increment(size_increment)
1052 .ts_event(UnixNanos::default())
1053 .ts_init(UnixNanos::default())
1054 .build()
1055 .unwrap();
1056 let fixed_scale = u32::from(FIXED_PRECISION);
1057 let fixed_price = Price::from_raw(
1058 PriceRaw::pow(10, fixed_scale) * PriceRaw::from(100),
1059 FIXED_PRECISION,
1060 );
1061 let fixed_qty = Quantity::from_raw(
1062 QuantityRaw::pow(10, fixed_scale) * QuantityRaw::from(100_u8),
1063 FIXED_PRECISION,
1064 );
1065
1066 let price_error = instrument.try_normalize_price(fixed_price).unwrap_err();
1067 let qty_error = instrument.try_normalize_qty(fixed_qty).unwrap_err();
1068
1069 assert!(matches!(
1070 price_error,
1071 CorrectnessError::PredicateViolation { ref message }
1072 if message.contains("raw scale does not match instrument price precision")
1073 ));
1074 assert!(matches!(
1075 qty_error,
1076 CorrectnessError::PredicateViolation { ref message }
1077 if message.contains("raw scale does not match instrument size precision")
1078 ));
1079 }
1080
1081 #[rstest]
1082 fn try_normalize_qty_rejects_sub_increment_value() {
1083 let instrument = CurrencyPair::builder()
1084 .instrument_id(InstrumentId::from("TEST.VENUE"))
1085 .raw_symbol(Symbol::from("TEST"))
1086 .base_currency(Currency::from("BTC"))
1087 .quote_currency(Currency::from("USD"))
1088 .price_precision(2)
1089 .size_precision(2)
1090 .price_increment(Price::from("0.01"))
1091 .size_increment(Quantity::from("0.50"))
1092 .ts_event(UnixNanos::default())
1093 .ts_init(UnixNanos::default())
1094 .build()
1095 .unwrap();
1096
1097 assert_eq!(
1098 instrument.try_normalize_qty(Quantity::from("1.500")),
1099 Ok(Quantity::from("1.50"))
1100 );
1101 let error = instrument
1102 .try_normalize_qty(Quantity::from("1.20"))
1103 .unwrap_err();
1104
1105 assert!(matches!(
1106 error,
1107 CorrectnessError::PredicateViolation { ref message }
1108 if message.contains("not aligned to size increment")
1109 ));
1110 }
1111
1112 #[rstest]
1113 #[should_panic(expected = "value rounded to zero")]
1114 fn make_qty_rounds_to_zero(currency_pair_btcusdt: CurrencyPair) {
1115 currency_pair_btcusdt.make_qty(1e-12, None);
1116 }
1117
1118 #[rstest]
1119 fn notional_linear(currency_pair_btcusdt: CurrencyPair) {
1120 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1121 let price = currency_pair_btcusdt.make_price(10_000.0);
1122 let notional = currency_pair_btcusdt.calculate_notional_value(quantity, price, None);
1123 let expected = Money::new(20_000.0, currency_pair_btcusdt.quote_currency());
1124 assert_eq!(notional, expected);
1125 }
1126
1127 #[rstest]
1128 fn currency_pair_is_not_quanto(currency_pair_btcusdt: CurrencyPair) {
1129 assert!(!currency_pair_btcusdt.is_quanto());
1130 assert_eq!(currency_pair_btcusdt.cost_currency(), Currency::USDT());
1131 }
1132
1133 #[rstest]
1134 fn tick_navigation(currency_pair_btcusdt: CurrencyPair) {
1135 let start = 10_000.123_4;
1136 let bid_0 = currency_pair_btcusdt.next_bid_price(start, 0).unwrap();
1137 let bid_1 = currency_pair_btcusdt.next_bid_price(start, 1).unwrap();
1138 assert!(bid_1 < bid_0);
1139 let asks = currency_pair_btcusdt.next_ask_prices(start, 3);
1140 assert_eq!(asks.len(), 3);
1141 assert!(asks[0] > bid_0);
1142 }
1143
1144 #[rstest]
1145 fn tick_navigation_uses_tick_scheme() {
1146 let instrument = CurrencyPair::builder()
1147 .instrument_id(InstrumentId::from("TEST.VENUE"))
1148 .raw_symbol(Symbol::from("TEST"))
1149 .base_currency(Currency::from("BTC"))
1150 .quote_currency(Currency::from("USD"))
1151 .price_precision(2)
1152 .size_precision(2)
1153 .price_increment(Price::new(0.01, 2))
1154 .size_increment(Quantity::from("0.01"))
1155 .tick_scheme(Ustr::from("FIXED_PRECISION_1"))
1156 .ts_event(UnixNanos::default())
1157 .ts_init(UnixNanos::default())
1158 .build()
1159 .unwrap();
1160
1161 assert_eq!(
1162 instrument.tick_scheme(),
1163 Some(Ustr::from("FIXED_PRECISION_1"))
1164 );
1165 assert_eq!(instrument.next_bid_price(1.23, 0), Some(Price::new(1.2, 2)));
1166 assert_eq!(instrument.next_ask_price(1.23, 0), Some(Price::new(1.3, 2)));
1167 }
1168
1169 #[rstest]
1170 #[case("BOGUS")]
1171 #[case("FIXED_PRECISION_99")]
1172 fn invalid_tick_scheme_returns_error(#[case] tick_scheme: &str) {
1173 let err = CurrencyPair::builder()
1174 .instrument_id(InstrumentId::from("TEST.VENUE"))
1175 .raw_symbol(Symbol::from("TEST"))
1176 .base_currency(Currency::from("BTC"))
1177 .quote_currency(Currency::from("USD"))
1178 .price_precision(2)
1179 .size_precision(2)
1180 .price_increment(Price::new(0.01, 2))
1181 .size_increment(Quantity::from("0.01"))
1182 .tick_scheme(Ustr::from(tick_scheme))
1183 .ts_event(UnixNanos::default())
1184 .ts_init(UnixNanos::default())
1185 .build()
1186 .expect_err("invalid tick scheme must fail");
1187
1188 assert!(
1189 err.to_string()
1190 .contains("tick_scheme not found in tick schemes"),
1191 "{err}"
1192 );
1193 }
1194
1195 #[rstest]
1196 #[should_panic(expected = "'margin_init' not positive")]
1197 fn validate_negative_margin_init() {
1198 let size_increment = Quantity::new(0.01, 2);
1199 let multiplier = Quantity::new(1.0, 0);
1200
1201 validate_instrument_common(
1202 2,
1203 2, size_increment, multiplier, dec!(-0.01), dec!(0.01), None, None, None, None, None, None, None, None, )
1217 .expect_display(FAILED);
1218 }
1219
1220 #[rstest]
1221 #[should_panic(expected = "'margin_maint' not positive")]
1222 fn validate_negative_margin_maint() {
1223 let size_increment = Quantity::new(0.01, 2);
1224 let multiplier = Quantity::new(1.0, 0);
1225
1226 validate_instrument_common(
1227 2,
1228 2, size_increment, multiplier, dec!(0.01), dec!(-0.01), None, None, None, None, None, None, None, None, )
1242 .expect_display(FAILED);
1243 }
1244
1245 #[rstest]
1246 fn validate_negative_max_qty() {
1247 let quantity = Quantity::new(0.0, 0);
1248 let error = validate_instrument_common(
1249 2,
1250 2,
1251 Quantity::new(0.01, 2),
1252 Quantity::new(1.0, 0),
1253 dec!(0.01),
1254 dec!(0.01),
1255 None,
1256 None,
1257 Some(quantity),
1258 None,
1259 None,
1260 None,
1261 None,
1262 None,
1263 )
1264 .unwrap_err();
1265
1266 assert_eq!(
1267 error,
1268 CorrectnessError::NotPositive {
1269 param: "max_quantity".to_string(),
1270 value: "0".to_string(),
1271 type_name: "`Quantity`",
1272 }
1273 );
1274 }
1275
1276 #[rstest]
1277 fn make_price_negative_rounding(currency_pair_ethusdt: CurrencyPair) {
1278 let price = currency_pair_ethusdt.make_price(-123.456_789);
1279 assert!(price.as_f64() < 0.0);
1280 }
1281
1282 #[rstest]
1283 fn base_quantity_linear(currency_pair_btcusdt: CurrencyPair) {
1284 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1285 let price = currency_pair_btcusdt.make_price(10_000.0);
1286 let base = currency_pair_btcusdt.calculate_base_quantity(quantity, price);
1287 assert_eq!(base.to_string(), "0.000200");
1288 }
1289
1290 #[rstest]
1291 fn base_quantity_zero_last_price_returns_error(currency_pair_btcusdt: CurrencyPair) {
1292 let quantity = currency_pair_btcusdt.make_qty(2.0, None);
1293 let error = currency_pair_btcusdt
1294 .try_calculate_base_quantity(quantity, Price::new(0.0, 2))
1295 .unwrap_err();
1296 assert!(
1297 error.to_string().contains("`last_price` was zero"),
1298 "{error}"
1299 );
1300 }
1301
1302 #[rstest]
1303 #[case(f64::NAN)]
1304 #[case(f64::INFINITY)]
1305 #[case(1e30)] fn make_price_invalid_value_returns_error(
1307 currency_pair_btcusdt: CurrencyPair,
1308 #[case] value: f64,
1309 ) {
1310 let error = currency_pair_btcusdt.try_make_price(value).unwrap_err();
1311 assert!(
1312 error.to_string().contains("invalid `value` for make_price"),
1313 "{error}"
1314 );
1315 }
1316
1317 #[rstest]
1318 fn make_qty_invalid_value_returns_error(currency_pair_btcusdt: CurrencyPair) {
1319 let error = currency_pair_btcusdt
1320 .try_make_qty(f64::NAN, None)
1321 .unwrap_err();
1322 assert!(
1323 error.to_string().contains("invalid `value` for make_qty"),
1324 "{error}"
1325 );
1326 }
1327
1328 #[rstest]
1329 fn next_bid_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1330 let start = 10_000.0;
1331 let bids = currency_pair_btcusdt.next_bid_prices(start, 5);
1332 assert_eq!(bids.len(), 5);
1333 for i in 1..bids.len() {
1334 assert!(bids[i] < bids[i - 1]);
1335 }
1336 }
1337
1338 #[rstest]
1339 fn next_ask_prices_sequence(currency_pair_btcusdt: CurrencyPair) {
1340 let start = 10_000.0;
1341 let asks = currency_pair_btcusdt.next_ask_prices(start, 5);
1342 assert_eq!(asks.len(), 5);
1343 for i in 1..asks.len() {
1344 assert!(asks[i] > asks[i - 1]);
1345 }
1346 }
1347
1348 #[rstest]
1349 #[case::bid(true)]
1350 #[case::ask(false)]
1351 fn tick_navigation_rejects_negative_offset(
1352 currency_pair_btcusdt: CurrencyPair,
1353 #[case] bid: bool,
1354 ) {
1355 let price = if bid {
1356 currency_pair_btcusdt.next_bid_price(10_000.0, -1)
1357 } else {
1358 currency_pair_btcusdt.next_ask_price(10_000.0, -1)
1359 };
1360
1361 assert_eq!(price, None);
1362 }
1363
1364 #[rstest]
1365 fn validate_price_increment_precision_mismatch() {
1366 let size_increment = Quantity::new(0.01, 2);
1367 let multiplier = Quantity::new(1.0, 0);
1368 let price_increment = Price::new(0.001, 3);
1369 let error = validate_instrument_common(
1370 2,
1371 2,
1372 size_increment,
1373 multiplier,
1374 dec!(0.01),
1375 dec!(0.01),
1376 Some(price_increment),
1377 None,
1378 None,
1379 None,
1380 None,
1381 None,
1382 None,
1383 None,
1384 )
1385 .unwrap_err();
1386
1387 assert_eq!(
1388 error,
1389 CorrectnessError::EqualityMismatch {
1390 lhs_param: "price_increment.precision".to_string(),
1391 rhs_param: "price_precision".to_string(),
1392 lhs: "3".to_string(),
1393 rhs: "2".to_string(),
1394 type_name: "u8",
1395 }
1396 );
1397 }
1398
1399 #[rstest]
1400 fn validate_min_price_exceeds_max_price() {
1401 let size_increment = Quantity::new(0.01, 2);
1402 let multiplier = Quantity::new(1.0, 0);
1403 let min_price = Price::new(10.0, 2);
1404 let max_price = Price::new(5.0, 2);
1405 let error = validate_instrument_common(
1406 2,
1407 2,
1408 size_increment,
1409 multiplier,
1410 dec!(0.01),
1411 dec!(0.01),
1412 None,
1413 None,
1414 None,
1415 None,
1416 None,
1417 None,
1418 Some(max_price),
1419 Some(min_price),
1420 )
1421 .unwrap_err();
1422
1423 assert_eq!(
1424 error,
1425 CorrectnessError::PredicateViolation {
1426 message: "min_price exceeds max_price".to_string(),
1427 }
1428 );
1429 }
1430
1431 #[rstest]
1432 fn validate_instrument_common_ok() {
1433 let res = validate_instrument_common(
1434 2,
1435 4,
1436 Quantity::new(0.0001, 4),
1437 Quantity::new(1.0, 0),
1438 dec!(0.02),
1439 dec!(0.01),
1440 Some(Price::new(0.01, 2)),
1441 None,
1442 None,
1443 None,
1444 None,
1445 None,
1446 None,
1447 None,
1448 );
1449 assert!(matches!(res, Ok(())));
1450 }
1451
1452 #[rstest]
1453 #[should_panic(expected = "not in range")]
1454 fn validate_multiple_errors() {
1455 validate_instrument_common(
1456 2,
1457 2,
1458 Quantity::new(-0.01, 2),
1459 Quantity::new(0.0, 0),
1460 dec!(0),
1461 dec!(0),
1462 None,
1463 None,
1464 None,
1465 None,
1466 None,
1467 None,
1468 None,
1469 None,
1470 )
1471 .expect_display(FAILED);
1472 }
1473
1474 #[rstest]
1475 #[case(1.234_999_9, false, "1.235000")]
1476 #[case(1.234_999_9, true, "1.234999")]
1477 fn make_qty_boundary(
1478 currency_pair_btcusdt: CurrencyPair,
1479 #[case] input: f64,
1480 #[case] round_down: bool,
1481 #[case] expected: &str,
1482 ) {
1483 let quantity = currency_pair_btcusdt.make_qty(input, Some(round_down));
1484 assert_eq!(quantity.to_string(), expected);
1485 }
1486
1487 #[rstest]
1488 #[case(1.234_999, 1.23)]
1489 #[case(1.235, 1.24)]
1490 #[case(1.235_001, 1.24)]
1491 fn make_price_rounding_parity(
1492 currency_pair_btcusdt: CurrencyPair,
1493 #[case] input: f64,
1494 #[case] expected: f64,
1495 ) {
1496 let price = currency_pair_btcusdt.make_price(input);
1497 assert!((price.as_f64() - expected).abs() < 1e-9);
1498 }
1499
1500 #[rstest]
1501 fn make_price_half_even_parity(currency_pair_btcusdt: CurrencyPair) {
1502 let rounding_precision = std::cmp::min(
1503 currency_pair_btcusdt.price_precision(),
1504 currency_pair_btcusdt.min_price_increment_precision(),
1505 );
1506 let step = 10f64.powi(-i32::from(rounding_precision));
1507 let base_even_multiple = 42.0;
1508 let base_value = step * base_even_multiple;
1509 let delta = step / 2000.0;
1510 let value_below = base_value + 0.5 * step - delta;
1511 let value_exact = base_value + 0.5 * step;
1512 let value_above = base_value + 0.5 * step + delta;
1513 let price_below = currency_pair_btcusdt.make_price(value_below);
1514 let price_exact = currency_pair_btcusdt.make_price(value_exact);
1515 let price_above = currency_pair_btcusdt.make_price(value_above);
1516 assert_eq!(price_below, price_exact);
1517 assert_ne!(price_exact, price_above);
1518 }
1519
1520 #[rstest]
1521 #[case(dec!(1.234999), dec!(1.23))]
1522 #[case(dec!(1.235), dec!(1.24))]
1523 #[case(dec!(1.235001), dec!(1.24))]
1524 #[case(dec!(10000.0), dec!(10000.0))]
1525 fn make_price_from_decimal_matches_f64_path(
1526 currency_pair_btcusdt: CurrencyPair,
1527 #[case] value: Decimal,
1528 #[case] expected: Decimal,
1529 ) {
1530 let from_decimal = currency_pair_btcusdt.make_price_from_decimal(value);
1531 let from_f64 = currency_pair_btcusdt.make_price(value.to_string().parse::<f64>().unwrap());
1532 assert_eq!(from_decimal, from_f64);
1533 assert_eq!(from_decimal.as_decimal(), expected);
1534 }
1535
1536 #[rstest]
1537 fn is_quanto_flag(ethbtc_quanto: CryptoFuture) {
1538 assert!(ethbtc_quanto.is_quanto());
1539 }
1540
1541 #[rstest]
1542 fn notional_quanto(ethbtc_quanto: CryptoFuture) {
1543 let quantity = ethbtc_quanto.make_qty(5.0, None);
1544 let price = ethbtc_quanto.make_price(0.036);
1545 let notional = ethbtc_quanto.calculate_notional_value(quantity, price, None);
1546 let expected = Money::new(0.18, ethbtc_quanto.settlement_currency());
1547 assert_eq!(notional, expected);
1548 }
1549
1550 #[rstest]
1551 #[case("USD", "BUSD")]
1552 #[case("USD", "FDUSD")]
1553 #[case("USD", "pUSD")]
1554 #[case("USD", "TUSD")]
1555 #[case("USD", "USD")]
1556 #[case("USD", "USDC")]
1557 #[case("USD", "USDC.e")]
1558 #[case("USD", "USDP")]
1559 #[case("USD", "USDT")]
1560 #[case("BUSD", "USD")]
1561 #[case("FDUSD", "USD")]
1562 #[case("pUSD", "USD")]
1563 #[case("TUSD", "USD")]
1564 #[case("USDC", "USD")]
1565 #[case("USDC.e", "USD")]
1566 #[case("USDP", "USD")]
1567 #[case("USDT", "USD")]
1568 fn usd_equivalent_settlement_is_not_quanto(
1569 #[case] quote_currency_code: &str,
1570 #[case] settlement_currency_code: &str,
1571 ) {
1572 let quote_currency =
1573 Currency::try_from_str(quote_currency_code).expect("quote currency must exist");
1574 let settlement_currency = Currency::try_from_str(settlement_currency_code)
1575 .expect("settlement currency must exist");
1576 let instrument = crypto_future_with_quote_settlement(quote_currency, settlement_currency);
1577 let quantity = instrument.make_qty(5.0, None);
1578 let price = instrument.make_price(1000.0);
1579 let notional = instrument.calculate_notional_value(quantity, price, None);
1580
1581 assert!(!instrument.is_quanto());
1582 assert_eq!(instrument.cost_currency(), quote_currency);
1583 assert_eq!(notional, Money::new(5000.0, quote_currency));
1584 }
1585
1586 #[rstest]
1587 fn notional_inverse_base(xbtusd_inverse_perp: CryptoPerpetual) {
1588 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1589 let price = xbtusd_inverse_perp.make_price(50_000.0);
1590 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(false));
1591 let expected = Money::new(
1592 100.0 * xbtusd_inverse_perp.multiplier().as_f64() * (1.0 / 50_000.0),
1593 xbtusd_inverse_perp.base_currency().unwrap(),
1594 );
1595 assert_eq!(notional, expected);
1596 }
1597
1598 #[rstest]
1599 fn notional_inverse_quote_use_quote(xbtusd_inverse_perp: CryptoPerpetual) {
1600 let quantity = xbtusd_inverse_perp.make_qty(100.0, None);
1601 let price = xbtusd_inverse_perp.make_price(50_000.0);
1602 let notional = xbtusd_inverse_perp.calculate_notional_value(quantity, price, Some(true));
1603 let expected = Money::new(100.0, xbtusd_inverse_perp.quote_currency());
1604 assert_eq!(notional, expected);
1605 }
1606
1607 #[rstest]
1608 fn try_notional_inverse_zero_price_returns_error(xbtusd_inverse_perp: CryptoPerpetual) {
1609 let result = xbtusd_inverse_perp.try_calculate_notional_value(
1610 xbtusd_inverse_perp.make_qty(100.0, None),
1611 Price::new(0.0, 1),
1612 Some(false),
1613 );
1614
1615 assert_eq!(
1616 result.unwrap_err().to_string(),
1617 "price must be positive for inverse notional valuation"
1618 );
1619 }
1620
1621 #[rstest]
1622 fn try_notional_unrepresentable_money_returns_error(currency_pair_btcusdt: CurrencyPair) {
1623 let result = currency_pair_btcusdt.try_calculate_notional_value(
1624 Quantity::from("100000000"),
1625 Price::from("100000000"),
1626 None,
1627 );
1628
1629 assert!(result.is_err());
1630 }
1631
1632 #[rstest]
1633 fn try_notional_decimal_overflow_returns_error() {
1634 let result = try_notional_value(
1635 Quantity::from("9000000000"),
1636 Price::from("9000000000"),
1637 Quantity::from("9000000000"),
1638 false,
1639 false,
1640 Currency::USD(),
1641 );
1642
1643 assert_eq!(
1644 result.unwrap_err().to_string(),
1645 "notional calculation overflow"
1646 );
1647 }
1648
1649 #[rstest]
1650 fn validate_non_positive_max_price() {
1651 let size_increment = Quantity::new(0.01, 2);
1652 let multiplier = Quantity::new(1.0, 0);
1653 let max_price = Price::new(0.0, 2);
1654 let error = validate_instrument_common(
1655 2,
1656 2,
1657 size_increment,
1658 multiplier,
1659 dec!(0.01),
1660 dec!(0.01),
1661 None,
1662 None,
1663 None,
1664 None,
1665 None,
1666 None,
1667 Some(max_price),
1668 None,
1669 )
1670 .unwrap_err();
1671
1672 assert_eq!(
1673 error,
1674 CorrectnessError::NotPositive {
1675 param: "max_price".to_string(),
1676 value: "0.00".to_string(),
1677 type_name: "`Price`",
1678 }
1679 );
1680 }
1681
1682 #[rstest]
1683 fn validate_non_positive_max_notional(currency_pair_btcusdt: CurrencyPair) {
1684 let size_increment = Quantity::new(0.01, 2);
1685 let multiplier = Quantity::new(1.0, 0);
1686 let max_notional = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1687 let error = validate_instrument_common(
1688 2,
1689 2,
1690 size_increment,
1691 multiplier,
1692 dec!(0.01),
1693 dec!(0.01),
1694 None,
1695 None,
1696 None,
1697 None,
1698 Some(max_notional),
1699 None,
1700 None,
1701 None,
1702 )
1703 .unwrap_err();
1704
1705 assert_eq!(
1706 error,
1707 CorrectnessError::NotPositive {
1708 param: "max_notional".to_string(),
1709 value: "0.00000000 USDT".to_string(),
1710 type_name: "`Money`",
1711 }
1712 );
1713 }
1714
1715 #[rstest]
1716 fn validate_price_increment_min_price_precision_mismatch() {
1717 let size_increment = Quantity::new(0.01, 2);
1718 let multiplier = Quantity::new(1.0, 0);
1719 let price_increment = Price::new(0.01, 2);
1720 let min_price = Price::new(1.0, 3);
1721 let error = validate_instrument_common(
1722 2,
1723 2,
1724 size_increment,
1725 multiplier,
1726 dec!(0.01),
1727 dec!(0.01),
1728 Some(price_increment),
1729 None,
1730 None,
1731 None,
1732 None,
1733 None,
1734 None,
1735 Some(min_price),
1736 )
1737 .unwrap_err();
1738
1739 assert_eq!(
1740 error,
1741 CorrectnessError::EqualityMismatch {
1742 lhs_param: "min_price.precision".to_string(),
1743 rhs_param: "price_precision".to_string(),
1744 lhs: "3".to_string(),
1745 rhs: "2".to_string(),
1746 type_name: "u8",
1747 }
1748 );
1749 }
1750
1751 #[rstest]
1752 fn validate_negative_min_notional(currency_pair_btcusdt: CurrencyPair) {
1753 let size_increment = Quantity::new(0.01, 2);
1754 let multiplier = Quantity::new(1.0, 0);
1755 let min_notional = Money::new(-1.0, currency_pair_btcusdt.quote_currency());
1756 let max_notional = Money::new(1.0, currency_pair_btcusdt.quote_currency());
1757 let error = validate_instrument_common(
1758 2,
1759 2,
1760 size_increment,
1761 multiplier,
1762 dec!(0.01),
1763 dec!(0.01),
1764 None,
1765 None,
1766 None,
1767 None,
1768 Some(max_notional),
1769 Some(min_notional),
1770 None,
1771 None,
1772 )
1773 .unwrap_err();
1774
1775 assert_eq!(
1776 error,
1777 CorrectnessError::NotPositive {
1778 param: "min_notional".to_string(),
1779 value: "-1.00000000 USDT".to_string(),
1780 type_name: "`Money`",
1781 }
1782 );
1783 }
1784
1785 #[rstest]
1786 #[case::dp0(Decimal::new(1_000, 0), Decimal::new(2, 0), 500.0)]
1787 #[case::dp1(Decimal::new(10_000, 1), Decimal::new(2, 0), 500.0)]
1788 #[case::dp2(Decimal::new(100_000, 2), Decimal::new(2, 0), 500.0)]
1789 #[case::dp3(Decimal::new(1_000_000, 3), Decimal::new(2, 0), 500.0)]
1790 #[case::dp4(Decimal::new(10_000_000, 4), Decimal::new(2, 0), 500.0)]
1791 #[case::dp5(Decimal::new(100_000_000, 5), Decimal::new(2, 0), 500.0)]
1792 #[case::dp6(Decimal::new(1_000_000_000, 6), Decimal::new(2, 0), 500.0)]
1793 #[case::dp7(Decimal::new(10_000_000_000, 7), Decimal::new(2, 0), 500.0)]
1794 #[case::dp8(Decimal::new(100_000_000_000, 8), Decimal::new(2, 0), 500.0)]
1795 fn base_qty_rounding(
1796 currency_pair_btcusdt: CurrencyPair,
1797 #[case] q: Decimal,
1798 #[case] px: Decimal,
1799 #[case] expected: f64,
1800 ) {
1801 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1802 let price = Price::new(px.to_f64().unwrap(), 8);
1803 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1804 assert!((base.as_f64() - expected).abs() < 1e-9);
1805 }
1806
1807 proptest! {
1808 #[rstest]
1809 fn make_price_qty_fuzz(input in 0.0001f64..1e8) {
1810 let instrument = currency_pair_btcusdt();
1811 let price = instrument.make_price(input);
1812 prop_assert!(price.as_f64().is_finite());
1813 let quantity = instrument.make_qty(input, None);
1814 prop_assert!(quantity.as_f64().is_finite());
1815 }
1816 }
1817
1818 #[rstest]
1819 fn tick_walk_limits_btcusdt_ask(currency_pair_btcusdt: CurrencyPair) {
1820 if let Some(max_price) = currency_pair_btcusdt.max_price() {
1821 assert!(
1822 currency_pair_btcusdt
1823 .next_ask_price(max_price.as_f64(), 1)
1824 .is_none()
1825 );
1826 }
1827 }
1828
1829 #[rstest]
1830 fn tick_walk_limits_ethusdt_ask(currency_pair_ethusdt: CurrencyPair) {
1831 if let Some(max_price) = currency_pair_ethusdt.max_price() {
1832 assert!(
1833 currency_pair_ethusdt
1834 .next_ask_price(max_price.as_f64(), 1)
1835 .is_none()
1836 );
1837 }
1838 }
1839
1840 #[rstest]
1841 fn tick_walk_limits_btcusdt_bid(currency_pair_btcusdt: CurrencyPair) {
1842 if let Some(min_price) = currency_pair_btcusdt.min_price() {
1843 assert!(
1844 currency_pair_btcusdt
1845 .next_bid_price(min_price.as_f64(), 1)
1846 .is_none()
1847 );
1848 }
1849 }
1850
1851 #[rstest]
1852 fn tick_walk_limits_ethusdt_bid(currency_pair_ethusdt: CurrencyPair) {
1853 if let Some(min_price) = currency_pair_ethusdt.min_price() {
1854 assert!(
1855 currency_pair_ethusdt
1856 .next_bid_price(min_price.as_f64(), 1)
1857 .is_none()
1858 );
1859 }
1860 }
1861
1862 #[rstest]
1863 fn tick_walk_limits_quanto_ask(ethbtc_quanto: CryptoFuture) {
1864 if let Some(max_price) = ethbtc_quanto.max_price() {
1865 assert!(
1866 ethbtc_quanto
1867 .next_ask_price(max_price.as_f64(), 1)
1868 .is_none()
1869 );
1870 }
1871 }
1872
1873 #[rstest]
1874 #[case(0.999_999, false)]
1875 #[case(0.999_999, true)]
1876 #[case(1.000_000_1, false)]
1877 #[case(1.000_000_1, true)]
1878 #[case(1.234_5, false)]
1879 #[case(1.234_5, true)]
1880 #[case(2.345_5, false)]
1881 #[case(2.345_5, true)]
1882 #[case(0.000_999_999, false)]
1883 #[case(0.000_999_999, true)]
1884 fn quantity_rounding_grid(
1885 currency_pair_btcusdt: CurrencyPair,
1886 #[case] input: f64,
1887 #[case] round_down: bool,
1888 ) {
1889 let qty = currency_pair_btcusdt.make_qty(input, Some(round_down));
1890 assert!(qty.as_f64().is_finite());
1891 }
1892
1893 #[rstest]
1894 fn validate_price_increment_max_price_precision_mismatch() {
1895 let size_increment = Quantity::new(0.01, 2);
1896 let multiplier = Quantity::new(1.0, 0);
1897 let price_increment = Price::new(0.01, 2);
1898 let max_price = Price::new(1.0, 3);
1899 let error = validate_instrument_common(
1900 2,
1901 2,
1902 size_increment,
1903 multiplier,
1904 dec!(0.01),
1905 dec!(0.01),
1906 Some(price_increment),
1907 None,
1908 None,
1909 None,
1910 None,
1911 None,
1912 Some(max_price),
1913 None,
1914 )
1915 .unwrap_err();
1916
1917 assert_eq!(
1918 error,
1919 CorrectnessError::EqualityMismatch {
1920 lhs_param: "max_price.precision".to_string(),
1921 rhs_param: "price_precision".to_string(),
1922 lhs: "3".to_string(),
1923 rhs: "2".to_string(),
1924 type_name: "u8",
1925 }
1926 );
1927 }
1928
1929 #[rstest]
1930 #[case::dp9(Decimal::new(1_000_000_000_000, 9), Decimal::new(2, 0), 500.0)]
1931 #[case::dp10(Decimal::new(10_000_000_000_000, 10), Decimal::new(2, 0), 500.0)]
1932 #[case::dp11(Decimal::new(100_000_000_000_000, 11), Decimal::new(2, 0), 500.0)]
1933 #[case::dp12(Decimal::new(1_000_000_000_000_000, 12), Decimal::new(2, 0), 500.0)]
1934 #[case::dp13(Decimal::new(10_000_000_000_000_000, 13), Decimal::new(2, 0), 500.0)]
1935 #[case::dp14(Decimal::new(100_000_000_000_000_000, 14), Decimal::new(2, 0), 500.0)]
1936 #[case::dp15(Decimal::new(1_000_000_000_000_000_000, 15), Decimal::new(2, 0), 500.0)]
1937 #[case::dp16(
1938 Decimal::from_i128_with_scale(10_000_000_000_000_000_000i128, 16),
1939 Decimal::new(2, 0),
1940 500.0
1941 )]
1942 #[case::dp17(
1943 Decimal::from_i128_with_scale(100_000_000_000_000_000_000i128, 17),
1944 Decimal::new(2, 0),
1945 500.0
1946 )]
1947 fn base_qty_rounding_high_dp(
1948 currency_pair_btcusdt: CurrencyPair,
1949 #[case] q: Decimal,
1950 #[case] px: Decimal,
1951 #[case] expected: f64,
1952 ) {
1953 let qty = Quantity::new(q.to_f64().unwrap(), 8);
1954 let price = Price::new(px.to_f64().unwrap(), 8);
1955 let base = currency_pair_btcusdt.calculate_base_quantity(qty, price);
1956 assert!((base.as_f64() - expected).abs() < 1e-9);
1957 }
1958
1959 #[rstest]
1960 fn check_positive_money_ok(currency_pair_btcusdt: CurrencyPair) {
1961 let money = Money::new(100.0, currency_pair_btcusdt.quote_currency());
1962 assert!(check_positive_money(money, "money").is_ok());
1963 }
1964
1965 #[rstest]
1966 #[should_panic(expected = "NotPositive")]
1967 fn check_positive_money_zero(currency_pair_btcusdt: CurrencyPair) {
1968 let money = Money::new(0.0, currency_pair_btcusdt.quote_currency());
1969 check_positive_money(money, "money").unwrap();
1970 }
1971
1972 #[rstest]
1973 #[should_panic(expected = "NotPositive")]
1974 fn check_positive_money_negative(currency_pair_btcusdt: CurrencyPair) {
1975 let money = Money::new(-0.01, currency_pair_btcusdt.quote_currency());
1976 check_positive_money(money, "money").unwrap();
1977 }
1978
1979 fn crypto_future_with_quote_settlement(
1980 quote_currency: Currency,
1981 settlement_currency: Currency,
1982 ) -> CryptoFuture {
1983 CryptoFuture::builder()
1984 .instrument_id(InstrumentId::from("ETHUSD-QUANTO-TEST.BINANCE"))
1985 .raw_symbol(Symbol::from("ETHUSD-QUANTO-TEST"))
1986 .underlying(Currency::ETH())
1987 .quote_currency(quote_currency)
1988 .settlement_currency(settlement_currency)
1989 .is_inverse(false)
1990 .activation_ns(0.into())
1991 .expiration_ns(0.into())
1992 .price_precision(2)
1993 .size_precision(0)
1994 .price_increment(Price::from("0.01"))
1995 .size_increment(Quantity::from("1"))
1996 .ts_event(0.into())
1997 .ts_init(0.into())
1998 .build()
1999 .unwrap()
2000 }
2001
2002 #[rstest]
2003 fn make_price_with_trailing_zeros_in_increment() {
2004 let instrument = CurrencyPair::builder()
2007 .instrument_id(InstrumentId::from("TEST.VENUE"))
2008 .raw_symbol(Symbol::from("TEST"))
2009 .base_currency(Currency::from("BTC"))
2010 .quote_currency(Currency::from("USD"))
2011 .price_precision(2)
2012 .size_precision(2)
2013 .price_increment(Price::new(0.50, 2))
2015 .size_increment(Quantity::from("0.01"))
2016 .ts_event(UnixNanos::default())
2017 .ts_init(UnixNanos::default())
2018 .build()
2019 .unwrap();
2020
2021 assert_eq!(instrument.min_price_increment_precision(), 1);
2023
2024 let price = instrument.make_price(1.234);
2027 assert_eq!(price.as_f64(), 1.2);
2028
2029 let price = instrument.make_price(1.25);
2031 assert_eq!(price.as_f64(), 1.2);
2032
2033 let price = instrument.make_price(1.35);
2035 assert_eq!(price.as_f64(), 1.4);
2036
2037 assert_eq!(price.precision, 2);
2039 }
2040
2041 #[rstest]
2042 fn make_qty_with_trailing_zeros_in_increment() {
2043 let instrument = CurrencyPair::builder()
2045 .instrument_id(InstrumentId::from("TEST.VENUE"))
2046 .raw_symbol(Symbol::from("TEST"))
2047 .base_currency(Currency::from("BTC"))
2048 .quote_currency(Currency::from("USD"))
2049 .price_precision(2)
2050 .size_precision(2)
2051 .price_increment(Price::new(0.01, 2))
2052 .size_increment(Quantity::new(0.50, 2))
2054 .ts_event(UnixNanos::default())
2055 .ts_init(UnixNanos::default())
2056 .build()
2057 .unwrap();
2058
2059 assert_eq!(instrument.min_size_increment_precision(), 1);
2061
2062 let qty = instrument.make_qty(1.234, None);
2065 assert_eq!(qty.as_f64(), 1.2);
2066
2067 let qty = instrument.make_qty(1.25, None);
2069 assert_eq!(qty.as_f64(), 1.2);
2070
2071 let qty = instrument.make_qty(1.35, None);
2073 assert_eq!(qty.as_f64(), 1.4);
2074
2075 assert_eq!(qty.precision, 2);
2077
2078 let qty = instrument.make_qty(1.99, Some(true));
2080 assert_eq!(qty.as_f64(), 1.9);
2081 }
2082
2083 #[rstest]
2084 #[case(InstrumentClass::Future, true)]
2085 #[case(InstrumentClass::FuturesSpread, true)]
2086 #[case(InstrumentClass::Option, true)]
2087 #[case(InstrumentClass::OptionSpread, true)]
2088 #[case(InstrumentClass::Spot, false)]
2089 #[case(InstrumentClass::Swap, false)]
2090 #[case(InstrumentClass::Forward, false)]
2091 #[case(InstrumentClass::Cfd, false)]
2092 #[case(InstrumentClass::Bond, false)]
2093 #[case(InstrumentClass::Warrant, false)]
2094 #[case(InstrumentClass::SportsBetting, false)]
2095 #[case(InstrumentClass::BinaryOption, false)]
2096 fn test_instrument_class_has_expiration(
2097 #[case] instrument_class: InstrumentClass,
2098 #[case] expected: bool,
2099 ) {
2100 assert_eq!(instrument_class.has_expiration(), expected);
2101 }
2102}