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 tick_scheme(&self) -> Option<Ustr> {
294 self.tick_scheme
295 }
296 fn into_any(self) -> InstrumentAny {
297 InstrumentAny::PerpetualContract(self)
298 }
299
300 fn id(&self) -> InstrumentId {
301 self.id
302 }
303
304 fn raw_symbol(&self) -> Symbol {
305 self.raw_symbol
306 }
307
308 fn asset_class(&self) -> AssetClass {
309 self.asset_class
310 }
311
312 fn instrument_class(&self) -> InstrumentClass {
313 InstrumentClass::Swap
314 }
315
316 fn underlying(&self) -> Option<Ustr> {
317 Some(self.underlying)
318 }
319
320 fn base_currency(&self) -> Option<Currency> {
321 self.base_currency
322 }
323
324 fn quote_currency(&self) -> Currency {
325 self.quote_currency
326 }
327
328 fn settlement_currency(&self) -> Currency {
329 self.settlement_currency
330 }
331
332 fn isin(&self) -> Option<Ustr> {
333 None
334 }
335
336 fn option_kind(&self) -> Option<OptionKind> {
337 None
338 }
339
340 fn exchange(&self) -> Option<Ustr> {
341 None
342 }
343
344 fn strike_price(&self) -> Option<Price> {
345 None
346 }
347
348 fn activation_ns(&self) -> Option<UnixNanos> {
349 None
350 }
351
352 fn expiration_ns(&self) -> Option<UnixNanos> {
353 None
354 }
355
356 fn is_inverse(&self) -> bool {
357 self.is_inverse
358 }
359
360 fn price_precision(&self) -> u8 {
361 self.price_precision
362 }
363
364 fn size_precision(&self) -> u8 {
365 self.size_precision
366 }
367
368 fn price_increment(&self) -> Price {
369 self.price_increment
370 }
371
372 fn size_increment(&self) -> Quantity {
373 self.size_increment
374 }
375
376 fn multiplier(&self) -> Quantity {
377 self.multiplier
378 }
379
380 fn lot_size(&self) -> Option<Quantity> {
381 Some(self.lot_size)
382 }
383
384 fn max_quantity(&self) -> Option<Quantity> {
385 self.max_quantity
386 }
387
388 fn min_quantity(&self) -> Option<Quantity> {
389 self.min_quantity
390 }
391
392 fn max_notional(&self) -> Option<Money> {
393 self.max_notional
394 }
395
396 fn min_notional(&self) -> Option<Money> {
397 self.min_notional
398 }
399
400 fn max_price(&self) -> Option<Price> {
401 self.max_price
402 }
403
404 fn min_price(&self) -> Option<Price> {
405 self.min_price
406 }
407
408 fn margin_init(&self) -> Decimal {
409 self.margin_init
410 }
411
412 fn margin_maint(&self) -> Decimal {
413 self.margin_maint
414 }
415
416 fn maker_fee(&self) -> Decimal {
417 self.maker_fee
418 }
419
420 fn taker_fee(&self) -> Decimal {
421 self.taker_fee
422 }
423
424 fn ts_event(&self) -> UnixNanos {
425 self.ts_event
426 }
427
428 fn ts_init(&self) -> UnixNanos {
429 self.ts_init
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use rstest::rstest;
436 use rust_decimal_macros::dec;
437 use ustr::Ustr;
438
439 use crate::{
440 enums::{AssetClass, InstrumentClass},
441 identifiers::{InstrumentId, Symbol},
442 instruments::{Instrument, PerpetualContract, stubs::*},
443 types::{Currency, Money, Price, Quantity},
444 };
445
446 #[rstest]
447 fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
448 assert_eq!(
449 perpetual_contract_eurusd.id(),
450 InstrumentId::from("EURUSD-PERP.AX"),
451 );
452 assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
453 assert_eq!(
454 perpetual_contract_eurusd.instrument_class(),
455 InstrumentClass::Swap
456 );
457 assert_eq!(
458 perpetual_contract_eurusd.base_currency(),
459 Some(Currency::EUR())
460 );
461 assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
462 assert_eq!(
463 perpetual_contract_eurusd.settlement_currency(),
464 Currency::USD()
465 );
466 assert!(!perpetual_contract_eurusd.is_inverse());
467 assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
468 assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
469 assert_eq!(
470 perpetual_contract_eurusd.price_increment(),
471 Price::from("0.00001")
472 );
473 assert_eq!(
474 perpetual_contract_eurusd.size_increment(),
475 Quantity::from("1")
476 );
477 assert_eq!(
478 perpetual_contract_eurusd.underlying(),
479 Some(Ustr::from("EURUSD")),
480 );
481 }
482
483 #[rstest]
484 fn test_new_checked_inverse_without_base_currency() {
485 let result = PerpetualContract::new_checked(
486 InstrumentId::from("TEST.EXCHANGE"),
487 Symbol::from("TEST"),
488 Ustr::from("TEST"),
489 AssetClass::FX,
490 None, Currency::USD(),
492 Currency::USD(),
493 true, 5,
495 0,
496 Price::from("0.00001"),
497 Quantity::from("1"),
498 None,
499 None,
500 None,
501 None,
502 None,
503 None,
504 None,
505 None,
506 None,
507 None,
508 None,
509 None,
510 None,
511 None,
512 0.into(),
513 0.into(),
514 );
515 assert!(result.is_err());
516 assert!(result.unwrap_err().to_string().contains("base_currency"),);
517 }
518
519 #[rstest]
520 fn test_new_checked_price_precision_mismatch() {
521 let result = PerpetualContract::new_checked(
522 InstrumentId::from("TEST.EXCHANGE"),
523 Symbol::from("TEST"),
524 Ustr::from("TEST"),
525 AssetClass::FX,
526 Some(Currency::EUR()),
527 Currency::USD(),
528 Currency::USD(),
529 false,
530 3, 0,
532 Price::from("0.00001"),
533 Quantity::from("1"),
534 None,
535 None,
536 None,
537 None,
538 None,
539 None,
540 None,
541 None,
542 None,
543 None,
544 None,
545 None,
546 None,
547 None,
548 0.into(),
549 0.into(),
550 );
551 assert!(result.is_err());
552 }
553
554 #[rstest]
555 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
556 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
557 fn test_new_checked_rejects_non_positive_sizing(
558 #[case] multiplier: Option<Quantity>,
559 #[case] lot_size: Option<Quantity>,
560 ) {
561 let result = PerpetualContract::new_checked(
562 InstrumentId::from("TEST.EXCHANGE"),
563 Symbol::from("TEST"),
564 Ustr::from("TEST"),
565 AssetClass::FX,
566 Some(Currency::EUR()),
567 Currency::USD(),
568 Currency::USD(),
569 false,
570 5,
571 0,
572 Price::from("0.00001"),
573 Quantity::from("1"),
574 multiplier,
575 lot_size,
576 None,
577 None,
578 None,
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 0.into(),
589 0.into(),
590 );
591 let error = result.unwrap_err();
592 assert!(error.to_string().contains("not positive"), "{error}");
593 }
594
595 #[rstest]
596 fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
597 let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
598 let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
599 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
600 }
601
602 #[rstest]
603 fn test_builder_matches_new_checked() {
604 let positional = PerpetualContract::new_checked(
605 InstrumentId::from("EURUSD-PERP.AX"),
606 Symbol::from("EURUSD-PERP"),
607 Ustr::from("EURUSD"),
608 AssetClass::FX,
609 Some(Currency::EUR()),
610 Currency::USD(),
611 Currency::BTC(),
612 false,
613 5,
614 0,
615 Price::from("0.00001"),
616 Quantity::from("1"),
617 Some(Quantity::from("10")),
618 Some(Quantity::from("5")),
619 Some(Quantity::from("100")),
620 Some(Quantity::from("1")),
621 Some(Money::new(1000.0, Currency::USD())),
622 Some(Money::new(10.0, Currency::USD())),
623 Some(Price::from("9.99999")),
624 Some(Price::from("0.00002")),
625 Some(dec!(0.01)),
626 Some(dec!(0.02)),
627 Some(dec!(0.0002)),
628 Some(dec!(0.0004)),
629 None,
630 None,
631 1.into(),
632 2.into(),
633 )
634 .unwrap();
635
636 let built = PerpetualContract::builder()
637 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
638 .raw_symbol(Symbol::from("EURUSD-PERP"))
639 .underlying(Ustr::from("EURUSD"))
640 .asset_class(AssetClass::FX)
641 .base_currency(Currency::EUR())
642 .quote_currency(Currency::USD())
643 .settlement_currency(Currency::BTC())
644 .is_inverse(false)
645 .price_precision(5)
646 .size_precision(0)
647 .price_increment(Price::from("0.00001"))
648 .size_increment(Quantity::from("1"))
649 .multiplier(Quantity::from("10"))
650 .lot_size(Quantity::from("5"))
651 .max_quantity(Quantity::from("100"))
652 .min_quantity(Quantity::from("1"))
653 .max_notional(Money::new(1000.0, Currency::USD()))
654 .min_notional(Money::new(10.0, Currency::USD()))
655 .max_price(Price::from("9.99999"))
656 .min_price(Price::from("0.00002"))
657 .margin_init(dec!(0.01))
658 .margin_maint(dec!(0.02))
659 .maker_fee(dec!(0.0002))
660 .taker_fee(dec!(0.0004))
661 .ts_event(1.into())
662 .ts_init(2.into())
663 .build()
664 .unwrap();
665
666 assert_eq!(
667 serde_json::to_value(&positional).unwrap(),
668 serde_json::to_value(&built).unwrap(),
669 );
670 }
671}