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