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