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