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