1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::any::InstrumentAny;
27use crate::{
28 enums::{AssetClass, InstrumentClass, OptionKind},
29 identifiers::{InstrumentId, Symbol},
30 instruments::{Instrument, tick_scheme::check_tick_scheme},
31 types::{
32 currency::Currency,
33 money::Money,
34 price::{Price, check_positive_price},
35 quantity::{Quantity, check_positive_quantity},
36 },
37};
38
39#[repr(C)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
45)]
46#[cfg_attr(
47 feature = "python",
48 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct CryptoPerpetual {
51 pub id: InstrumentId,
53 pub raw_symbol: Symbol,
55 pub base_currency: Currency,
57 pub quote_currency: Currency,
59 pub settlement_currency: Currency,
61 pub is_inverse: bool,
63 pub price_precision: u8,
65 pub size_precision: u8,
67 pub price_increment: Price,
69 pub size_increment: Quantity,
71 pub multiplier: Quantity,
73 pub lot_size: Quantity,
75 pub margin_init: Decimal,
77 pub margin_maint: Decimal,
79 pub maker_fee: Decimal,
81 pub taker_fee: Decimal,
83 pub max_quantity: Option<Quantity>,
85 pub min_quantity: Option<Quantity>,
87 pub max_notional: Option<Money>,
89 pub min_notional: Option<Money>,
91 pub max_price: Option<Price>,
93 pub min_price: Option<Price>,
95 pub tick_scheme: Option<Ustr>,
97 pub info: Option<Params>,
99 pub ts_event: UnixNanos,
101 pub ts_init: UnixNanos,
103}
104
105#[bon::bon]
106impl CryptoPerpetual {
107 #[expect(clippy::too_many_arguments)]
117 pub fn new_checked(
118 instrument_id: InstrumentId,
119 raw_symbol: Symbol,
120 base_currency: Currency,
121 quote_currency: Currency,
122 settlement_currency: Currency,
123 is_inverse: bool,
124 price_precision: u8,
125 size_precision: u8,
126 price_increment: Price,
127 size_increment: Quantity,
128 multiplier: Option<Quantity>,
129 lot_size: Option<Quantity>,
130 max_quantity: Option<Quantity>,
131 min_quantity: Option<Quantity>,
132 max_notional: Option<Money>,
133 min_notional: Option<Money>,
134 max_price: Option<Price>,
135 min_price: Option<Price>,
136 margin_init: Option<Decimal>,
137 margin_maint: Option<Decimal>,
138 maker_fee: Option<Decimal>,
139 taker_fee: Option<Decimal>,
140 tick_scheme: Option<Ustr>,
141 info: Option<Params>,
142 ts_event: UnixNanos,
143 ts_init: UnixNanos,
144 ) -> CorrectnessResult<Self> {
145 check_equal_u8(
146 price_precision,
147 price_increment.precision,
148 stringify!(price_precision),
149 stringify!(price_increment.precision),
150 )?;
151 check_equal_u8(
152 size_precision,
153 size_increment.precision,
154 stringify!(size_precision),
155 stringify!(size_increment.precision),
156 )?;
157 check_positive_price(price_increment, stringify!(price_increment))?;
158 check_positive_quantity(size_increment, stringify!(size_increment))?;
159 check_tick_scheme(tick_scheme)?;
160
161 if let Some(multiplier) = multiplier {
162 check_positive_quantity(multiplier, stringify!(multiplier))?;
163 }
164
165 if let Some(lot_size) = lot_size {
166 check_positive_quantity(lot_size, stringify!(lot_size))?;
167 }
168
169 Ok(Self {
170 id: instrument_id,
171 raw_symbol,
172 base_currency,
173 quote_currency,
174 settlement_currency,
175 is_inverse,
176 price_precision,
177 size_precision,
178 price_increment,
179 size_increment,
180 multiplier: multiplier.unwrap_or(Quantity::from(1)),
181 lot_size: lot_size.unwrap_or(Quantity::from(1)),
182 margin_init: margin_init.unwrap_or_default(),
183 margin_maint: margin_maint.unwrap_or_default(),
184 maker_fee: maker_fee.unwrap_or_default(),
185 taker_fee: taker_fee.unwrap_or_default(),
186 max_quantity,
187 min_quantity,
188 max_notional,
189 min_notional,
190 max_price,
191 min_price,
192 tick_scheme,
193 info,
194 ts_event,
195 ts_init,
196 })
197 }
198
199 #[expect(clippy::too_many_arguments)]
205 #[must_use]
206 pub fn new(
207 instrument_id: InstrumentId,
208 raw_symbol: Symbol,
209 base_currency: Currency,
210 quote_currency: Currency,
211 settlement_currency: Currency,
212 is_inverse: bool,
213 price_precision: u8,
214 size_precision: u8,
215 price_increment: Price,
216 size_increment: Quantity,
217 multiplier: Option<Quantity>,
218 lot_size: Option<Quantity>,
219 max_quantity: Option<Quantity>,
220 min_quantity: Option<Quantity>,
221 max_notional: Option<Money>,
222 min_notional: Option<Money>,
223 max_price: Option<Price>,
224 min_price: Option<Price>,
225 margin_init: Option<Decimal>,
226 margin_maint: Option<Decimal>,
227 maker_fee: Option<Decimal>,
228 taker_fee: Option<Decimal>,
229 tick_scheme: Option<Ustr>,
230 info: Option<Params>,
231 ts_event: UnixNanos,
232 ts_init: UnixNanos,
233 ) -> Self {
234 Self::new_checked(
235 instrument_id,
236 raw_symbol,
237 base_currency,
238 quote_currency,
239 settlement_currency,
240 is_inverse,
241 price_precision,
242 size_precision,
243 price_increment,
244 size_increment,
245 multiplier,
246 lot_size,
247 max_quantity,
248 min_quantity,
249 max_notional,
250 min_notional,
251 max_price,
252 min_price,
253 margin_init,
254 margin_maint,
255 maker_fee,
256 taker_fee,
257 tick_scheme,
258 info,
259 ts_event,
260 ts_init,
261 )
262 .expect_display(FAILED)
263 }
264
265 #[builder(start_fn = builder, finish_fn = build)]
275 pub fn build_checked(
276 instrument_id: InstrumentId,
277 raw_symbol: Symbol,
278 base_currency: Currency,
279 quote_currency: Currency,
280 settlement_currency: Currency,
281 is_inverse: bool,
282 price_precision: u8,
283 size_precision: u8,
284 price_increment: Price,
285 size_increment: Quantity,
286 multiplier: Option<Quantity>,
287 lot_size: Option<Quantity>,
288 max_quantity: Option<Quantity>,
289 min_quantity: Option<Quantity>,
290 max_notional: Option<Money>,
291 min_notional: Option<Money>,
292 max_price: Option<Price>,
293 min_price: Option<Price>,
294 margin_init: Option<Decimal>,
295 margin_maint: Option<Decimal>,
296 maker_fee: Option<Decimal>,
297 taker_fee: Option<Decimal>,
298 tick_scheme: Option<Ustr>,
299 info: Option<Params>,
300 ts_event: UnixNanos,
301 ts_init: UnixNanos,
302 ) -> CorrectnessResult<Self> {
303 Self::new_checked(
304 instrument_id,
305 raw_symbol,
306 base_currency,
307 quote_currency,
308 settlement_currency,
309 is_inverse,
310 price_precision,
311 size_precision,
312 price_increment,
313 size_increment,
314 multiplier,
315 lot_size,
316 max_quantity,
317 min_quantity,
318 max_notional,
319 min_notional,
320 max_price,
321 min_price,
322 margin_init,
323 margin_maint,
324 maker_fee,
325 taker_fee,
326 tick_scheme,
327 info,
328 ts_event,
329 ts_init,
330 )
331 }
332}
333
334impl PartialEq<Self> for CryptoPerpetual {
335 fn eq(&self, other: &Self) -> bool {
336 self.id == other.id
337 }
338}
339
340impl Eq for CryptoPerpetual {}
341
342impl Hash for CryptoPerpetual {
343 fn hash<H: Hasher>(&self, state: &mut H) {
344 self.id.hash(state);
345 }
346}
347
348impl Instrument for CryptoPerpetual {
349 fn tick_scheme(&self) -> Option<Ustr> {
350 self.tick_scheme
351 }
352 fn into_any(self) -> InstrumentAny {
353 InstrumentAny::CryptoPerpetual(self)
354 }
355
356 fn id(&self) -> InstrumentId {
357 self.id
358 }
359
360 fn raw_symbol(&self) -> Symbol {
361 self.raw_symbol
362 }
363
364 fn asset_class(&self) -> AssetClass {
365 AssetClass::Cryptocurrency
366 }
367
368 fn instrument_class(&self) -> InstrumentClass {
369 InstrumentClass::Swap
370 }
371 fn underlying(&self) -> Option<Ustr> {
372 None
373 }
374
375 fn base_currency(&self) -> Option<Currency> {
376 Some(self.base_currency)
377 }
378
379 fn quote_currency(&self) -> Currency {
380 self.quote_currency
381 }
382
383 fn settlement_currency(&self) -> Currency {
384 self.settlement_currency
385 }
386
387 fn isin(&self) -> Option<Ustr> {
388 None
389 }
390 fn option_kind(&self) -> Option<OptionKind> {
391 None
392 }
393 fn exchange(&self) -> Option<Ustr> {
394 None
395 }
396 fn strike_price(&self) -> Option<Price> {
397 None
398 }
399
400 fn activation_ns(&self) -> Option<UnixNanos> {
401 None
402 }
403
404 fn expiration_ns(&self) -> Option<UnixNanos> {
405 None
406 }
407
408 fn is_inverse(&self) -> bool {
409 self.is_inverse
410 }
411
412 fn price_precision(&self) -> u8 {
413 self.price_precision
414 }
415
416 fn size_precision(&self) -> u8 {
417 self.size_precision
418 }
419
420 fn price_increment(&self) -> Price {
421 self.price_increment
422 }
423
424 fn size_increment(&self) -> Quantity {
425 self.size_increment
426 }
427
428 fn multiplier(&self) -> Quantity {
429 self.multiplier
430 }
431
432 fn lot_size(&self) -> Option<Quantity> {
433 Some(self.lot_size)
434 }
435
436 fn max_quantity(&self) -> Option<Quantity> {
437 self.max_quantity
438 }
439
440 fn min_quantity(&self) -> Option<Quantity> {
441 self.min_quantity
442 }
443
444 fn max_notional(&self) -> Option<Money> {
445 self.max_notional
446 }
447
448 fn min_notional(&self) -> Option<Money> {
449 self.min_notional
450 }
451
452 fn max_price(&self) -> Option<Price> {
453 self.max_price
454 }
455
456 fn min_price(&self) -> Option<Price> {
457 self.min_price
458 }
459
460 fn margin_init(&self) -> Decimal {
461 self.margin_init
462 }
463
464 fn margin_maint(&self) -> Decimal {
465 self.margin_maint
466 }
467
468 fn maker_fee(&self) -> Decimal {
469 self.maker_fee
470 }
471
472 fn taker_fee(&self) -> Decimal {
473 self.taker_fee
474 }
475
476 fn ts_event(&self) -> UnixNanos {
477 self.ts_event
478 }
479
480 fn ts_init(&self) -> UnixNanos {
481 self.ts_init
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use rstest::rstest;
488 use rust_decimal::Decimal;
489 use rust_decimal_macros::dec;
490
491 use crate::{
492 enums::{AssetClass, InstrumentClass},
493 identifiers::{InstrumentId, Symbol},
494 instruments::{CryptoPerpetual, Instrument, stubs::*},
495 types::{Currency, Money, Price, Quantity},
496 };
497
498 #[rstest]
499 fn test_trait_accessors(crypto_perpetual_ethusdt: CryptoPerpetual) {
500 assert_eq!(
501 crypto_perpetual_ethusdt.id(),
502 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
503 );
504 assert_eq!(
505 crypto_perpetual_ethusdt.asset_class(),
506 AssetClass::Cryptocurrency
507 );
508 assert_eq!(
509 crypto_perpetual_ethusdt.instrument_class(),
510 InstrumentClass::Swap
511 );
512 assert_eq!(
513 crypto_perpetual_ethusdt.base_currency(),
514 Some(Currency::ETH())
515 );
516 assert_eq!(crypto_perpetual_ethusdt.quote_currency(), Currency::USDT());
517 assert_eq!(
518 crypto_perpetual_ethusdt.settlement_currency(),
519 Currency::USDT()
520 );
521 assert!(!crypto_perpetual_ethusdt.is_inverse());
522 assert_eq!(crypto_perpetual_ethusdt.price_precision(), 2);
523 assert_eq!(crypto_perpetual_ethusdt.size_precision(), 3);
524 assert_eq!(
525 crypto_perpetual_ethusdt.price_increment(),
526 Price::from("0.01")
527 );
528 assert_eq!(
529 crypto_perpetual_ethusdt.size_increment(),
530 Quantity::from("0.001")
531 );
532 assert_eq!(crypto_perpetual_ethusdt.multiplier(), Quantity::from("1"));
533 assert_eq!(
534 crypto_perpetual_ethusdt.lot_size(),
535 Some(Quantity::from("1"))
536 );
537 assert_eq!(
538 crypto_perpetual_ethusdt.max_quantity(),
539 Some(Quantity::from("10000.0")),
540 );
541 assert_eq!(
542 crypto_perpetual_ethusdt.min_quantity(),
543 Some(Quantity::from("0.001")),
544 );
545 assert_eq!(
546 crypto_perpetual_ethusdt.min_notional(),
547 Some(Money::new(10.00, Currency::USDT())),
548 );
549 assert_eq!(crypto_perpetual_ethusdt.underlying(), None);
550 assert_eq!(crypto_perpetual_ethusdt.option_kind(), None);
551 assert_eq!(crypto_perpetual_ethusdt.strike_price(), None);
552 assert_eq!(crypto_perpetual_ethusdt.activation_ns(), None);
553 assert_eq!(crypto_perpetual_ethusdt.expiration_ns(), None);
554 }
555
556 #[rstest]
557 fn test_inverse_perp_accessors(xbtusd_bitmex: CryptoPerpetual) {
558 assert!(xbtusd_bitmex.is_inverse());
559 assert_eq!(xbtusd_bitmex.base_currency(), Some(Currency::BTC()));
560 assert_eq!(xbtusd_bitmex.quote_currency(), Currency::USD());
561 assert_eq!(xbtusd_bitmex.settlement_currency(), Currency::BTC());
562 assert_eq!(xbtusd_bitmex.cost_currency(), Currency::BTC());
563 }
564
565 #[rstest]
566 fn test_new_checked_price_precision_mismatch() {
567 let result = CryptoPerpetual::new_checked(
568 InstrumentId::from("TEST.EXCHANGE"),
569 Symbol::from("TEST"),
570 Currency::BTC(),
571 Currency::USDT(),
572 Currency::USDT(),
573 false,
574 3, 0,
576 Price::from("0.01"),
577 Quantity::from("1"),
578 None,
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 None,
589 None,
590 None,
591 None,
592 0.into(),
593 0.into(),
594 );
595 assert!(result.is_err());
596 }
597
598 #[rstest]
599 fn test_new_checked_size_precision_mismatch() {
600 let result = CryptoPerpetual::new_checked(
601 InstrumentId::from("TEST.EXCHANGE"),
602 Symbol::from("TEST"),
603 Currency::BTC(),
604 Currency::USDT(),
605 Currency::USDT(),
606 false,
607 2,
608 5, Price::from("0.01"),
610 Quantity::from("1"),
611 None,
612 None,
613 None,
614 None,
615 None,
616 None,
617 None,
618 None,
619 None,
620 None,
621 None,
622 None,
623 None,
624 None,
625 0.into(),
626 0.into(),
627 );
628 assert!(result.is_err());
629 }
630
631 #[rstest]
632 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
633 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
634 fn test_new_checked_rejects_non_positive_sizing(
635 #[case] multiplier: Option<Quantity>,
636 #[case] lot_size: Option<Quantity>,
637 ) {
638 let result = CryptoPerpetual::new_checked(
639 InstrumentId::from("TEST.EXCHANGE"),
640 Symbol::from("TEST"),
641 Currency::BTC(),
642 Currency::USDT(),
643 Currency::USDT(),
644 false,
645 2,
646 0,
647 Price::from("0.01"),
648 Quantity::from("1"),
649 multiplier,
650 lot_size,
651 None,
652 None,
653 None,
654 None,
655 None,
656 None,
657 None,
658 None,
659 None,
660 None,
661 None,
662 None,
663 0.into(),
664 0.into(),
665 );
666 let error = result.unwrap_err();
667 assert!(error.to_string().contains("not positive"), "{error}");
668 }
669
670 #[rstest]
671 fn test_serialization_roundtrip(crypto_perpetual_ethusdt: CryptoPerpetual) {
672 let json = serde_json::to_string(&crypto_perpetual_ethusdt).unwrap();
673 let deserialized: CryptoPerpetual = serde_json::from_str(&json).unwrap();
674 assert_eq!(crypto_perpetual_ethusdt, deserialized);
675 }
676
677 #[rstest]
678 fn test_builder_matches_new_checked() {
679 let positional = CryptoPerpetual::new_checked(
680 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
681 Symbol::from("ETHUSDT"),
682 Currency::ETH(),
683 Currency::USDT(),
684 Currency::USDT(),
685 false,
686 2,
687 3,
688 Price::from("0.01"),
689 Quantity::from("0.001"),
690 None,
691 None,
692 Some(Quantity::from("10000.0")),
693 None,
694 None,
695 None,
696 None,
697 None,
698 None,
699 None,
700 None,
701 None,
702 None,
703 None,
704 0.into(),
705 0.into(),
706 )
707 .unwrap();
708
709 let built = CryptoPerpetual::builder()
710 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
711 .raw_symbol(Symbol::from("ETHUSDT"))
712 .base_currency(Currency::ETH())
713 .quote_currency(Currency::USDT())
714 .settlement_currency(Currency::USDT())
715 .is_inverse(false)
716 .price_precision(2)
717 .size_precision(3)
718 .price_increment(Price::from("0.01"))
719 .size_increment(Quantity::from("0.001"))
720 .max_quantity(Quantity::from("10000.0"))
721 .ts_event(0.into())
722 .ts_init(0.into())
723 .build()
724 .unwrap();
725
726 assert_eq!(
727 serde_json::to_value(&positional).unwrap(),
728 serde_json::to_value(&built).unwrap(),
729 );
730 }
731
732 #[rstest]
733 fn test_builder_applies_defaults_for_omitted_optionals() {
734 let perp = CryptoPerpetual::builder()
735 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
736 .raw_symbol(Symbol::from("ETHUSDT"))
737 .base_currency(Currency::ETH())
738 .quote_currency(Currency::USDT())
739 .settlement_currency(Currency::USDT())
740 .is_inverse(false)
741 .price_precision(2)
742 .size_precision(3)
743 .price_increment(Price::from("0.01"))
744 .size_increment(Quantity::from("0.001"))
745 .ts_event(0.into())
746 .ts_init(0.into())
747 .build()
748 .unwrap();
749
750 assert_eq!(perp.multiplier, Quantity::from(1));
751 assert_eq!(perp.lot_size, Quantity::from(1));
752 assert_eq!(perp.margin_init, Decimal::default());
753 assert_eq!(perp.margin_maint, Decimal::default());
754 assert_eq!(perp.maker_fee, Decimal::default());
755 assert_eq!(perp.taker_fee, Decimal::default());
756 assert_eq!(perp.max_quantity, None);
757 assert_eq!(perp.min_notional, None);
758 assert_eq!(perp.tick_scheme, None);
759 assert_eq!(perp.info, None);
760 }
761
762 #[rstest]
763 fn test_builder_sets_optional_fields_via_value_and_maybe_setters() {
764 let perp = CryptoPerpetual::builder()
765 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
766 .raw_symbol(Symbol::from("ETHUSDT"))
767 .base_currency(Currency::ETH())
768 .quote_currency(Currency::USDT())
769 .settlement_currency(Currency::USDT())
770 .is_inverse(false)
771 .price_precision(2)
772 .size_precision(3)
773 .price_increment(Price::from("0.01"))
774 .size_increment(Quantity::from("0.001"))
775 .max_quantity(Quantity::from("10000.0"))
776 .maybe_min_notional(Some(Money::new(10.00, Currency::USDT())))
777 .maker_fee(dec!(0.0002))
778 .ts_event(0.into())
779 .ts_init(0.into())
780 .build()
781 .unwrap();
782
783 assert_eq!(perp.max_quantity, Some(Quantity::from("10000.0")));
784 assert_eq!(perp.min_notional, Some(Money::new(10.00, Currency::USDT())));
785 assert_eq!(perp.maker_fee, dec!(0.0002));
786 }
787
788 #[rstest]
789 fn test_builder_propagates_validation_error() {
790 let result = CryptoPerpetual::builder()
791 .instrument_id(InstrumentId::from("TEST.EXCHANGE"))
792 .raw_symbol(Symbol::from("TEST"))
793 .base_currency(Currency::BTC())
794 .quote_currency(Currency::USDT())
795 .settlement_currency(Currency::USDT())
796 .is_inverse(false)
797 .price_precision(3) .size_precision(0)
799 .price_increment(Price::from("0.01"))
800 .size_increment(Quantity::from("1"))
801 .ts_event(0.into())
802 .ts_init(0.into())
803 .build();
804
805 assert!(result.is_err());
806 }
807}