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