1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{CorrectnessResult, 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.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)]
108 fn new_checked(
109 instrument_id: InstrumentId,
110 raw_symbol: Symbol,
111 base_currency: Currency,
112 quote_currency: Currency,
113 settlement_currency: Currency,
114 is_inverse: bool,
115 price_precision: u8,
116 size_precision: u8,
117 price_increment: Price,
118 size_increment: Quantity,
119 multiplier: Option<Quantity>,
120 lot_size: Option<Quantity>,
121 max_quantity: Option<Quantity>,
122 min_quantity: Option<Quantity>,
123 max_notional: Option<Money>,
124 min_notional: Option<Money>,
125 max_price: Option<Price>,
126 min_price: Option<Price>,
127 margin_init: Option<Decimal>,
128 margin_maint: Option<Decimal>,
129 maker_fee: Option<Decimal>,
130 taker_fee: Option<Decimal>,
131 tick_scheme: Option<Ustr>,
132 info: Option<Params>,
133 ts_event: UnixNanos,
134 ts_init: UnixNanos,
135 ) -> CorrectnessResult<Self> {
136 check_equal_u8(
137 price_precision,
138 price_increment.precision,
139 stringify!(price_precision),
140 stringify!(price_increment.precision),
141 )?;
142 check_equal_u8(
143 size_precision,
144 size_increment.precision,
145 stringify!(size_precision),
146 stringify!(size_increment.precision),
147 )?;
148 check_positive_price(price_increment, stringify!(price_increment))?;
149 check_positive_quantity(size_increment, stringify!(size_increment))?;
150 check_tick_scheme(tick_scheme)?;
151
152 if let Some(multiplier) = multiplier {
153 check_positive_quantity(multiplier, stringify!(multiplier))?;
154 }
155
156 if let Some(lot_size) = lot_size {
157 check_positive_quantity(lot_size, stringify!(lot_size))?;
158 }
159
160 Ok(Self {
161 id: instrument_id,
162 raw_symbol,
163 base_currency,
164 quote_currency,
165 settlement_currency,
166 is_inverse,
167 price_precision,
168 size_precision,
169 price_increment,
170 size_increment,
171 multiplier: multiplier.unwrap_or(Quantity::from(1)),
172 lot_size: lot_size.unwrap_or(Quantity::from(1)),
173 margin_init: margin_init.unwrap_or_default(),
174 margin_maint: margin_maint.unwrap_or_default(),
175 maker_fee: maker_fee.unwrap_or_default(),
176 taker_fee: taker_fee.unwrap_or_default(),
177 max_quantity,
178 min_quantity,
179 max_notional,
180 min_notional,
181 max_price,
182 min_price,
183 tick_scheme,
184 info,
185 ts_event,
186 ts_init,
187 })
188 }
189
190 #[builder(start_fn = builder, finish_fn = build)]
199 pub fn build_checked(
200 instrument_id: InstrumentId,
201 raw_symbol: Symbol,
202 base_currency: Currency,
203 quote_currency: Currency,
204 settlement_currency: Currency,
205 is_inverse: bool,
206 price_precision: u8,
207 size_precision: u8,
208 price_increment: Price,
209 size_increment: Quantity,
210 multiplier: Option<Quantity>,
211 lot_size: Option<Quantity>,
212 max_quantity: Option<Quantity>,
213 min_quantity: Option<Quantity>,
214 max_notional: Option<Money>,
215 min_notional: Option<Money>,
216 max_price: Option<Price>,
217 min_price: Option<Price>,
218 margin_init: Option<Decimal>,
219 margin_maint: Option<Decimal>,
220 maker_fee: Option<Decimal>,
221 taker_fee: Option<Decimal>,
222 tick_scheme: Option<Ustr>,
223 info: Option<Params>,
224 ts_event: UnixNanos,
225 ts_init: UnixNanos,
226 ) -> CorrectnessResult<Self> {
227 Self::new_checked(
228 instrument_id,
229 raw_symbol,
230 base_currency,
231 quote_currency,
232 settlement_currency,
233 is_inverse,
234 price_precision,
235 size_precision,
236 price_increment,
237 size_increment,
238 multiplier,
239 lot_size,
240 max_quantity,
241 min_quantity,
242 max_notional,
243 min_notional,
244 max_price,
245 min_price,
246 margin_init,
247 margin_maint,
248 maker_fee,
249 taker_fee,
250 tick_scheme,
251 info,
252 ts_event,
253 ts_init,
254 )
255 }
256}
257
258impl PartialEq<Self> for CryptoPerpetual {
259 fn eq(&self, other: &Self) -> bool {
260 self.id == other.id
261 }
262}
263
264impl Eq for CryptoPerpetual {}
265
266impl Hash for CryptoPerpetual {
267 fn hash<H: Hasher>(&self, state: &mut H) {
268 self.id.hash(state);
269 }
270}
271
272impl Instrument for CryptoPerpetual {
273 fn into_any(self) -> InstrumentAny {
274 InstrumentAny::CryptoPerpetual(self)
275 }
276
277 fn id(&self) -> InstrumentId {
278 self.id
279 }
280
281 fn raw_symbol(&self) -> Symbol {
282 self.raw_symbol
283 }
284
285 fn asset_class(&self) -> AssetClass {
286 AssetClass::Cryptocurrency
287 }
288
289 fn instrument_class(&self) -> InstrumentClass {
290 InstrumentClass::Swap
291 }
292 fn underlying(&self) -> Option<Ustr> {
293 None
294 }
295
296 fn base_currency(&self) -> Option<Currency> {
297 Some(self.base_currency)
298 }
299
300 fn quote_currency(&self) -> Currency {
301 self.quote_currency
302 }
303
304 fn settlement_currency(&self) -> Currency {
305 self.settlement_currency
306 }
307
308 fn isin(&self) -> Option<Ustr> {
309 None
310 }
311 fn option_kind(&self) -> Option<OptionKind> {
312 None
313 }
314 fn exchange(&self) -> Option<Ustr> {
315 None
316 }
317 fn strike_price(&self) -> Option<Price> {
318 None
319 }
320
321 fn activation_ns(&self) -> Option<UnixNanos> {
322 None
323 }
324
325 fn expiration_ns(&self) -> Option<UnixNanos> {
326 None
327 }
328
329 fn is_inverse(&self) -> bool {
330 self.is_inverse
331 }
332
333 fn price_precision(&self) -> u8 {
334 self.price_precision
335 }
336
337 fn size_precision(&self) -> u8 {
338 self.size_precision
339 }
340
341 fn price_increment(&self) -> Price {
342 self.price_increment
343 }
344
345 fn size_increment(&self) -> Quantity {
346 self.size_increment
347 }
348
349 fn multiplier(&self) -> Quantity {
350 self.multiplier
351 }
352
353 fn lot_size(&self) -> Option<Quantity> {
354 Some(self.lot_size)
355 }
356
357 fn max_quantity(&self) -> Option<Quantity> {
358 self.max_quantity
359 }
360
361 fn min_quantity(&self) -> Option<Quantity> {
362 self.min_quantity
363 }
364
365 fn max_notional(&self) -> Option<Money> {
366 self.max_notional
367 }
368
369 fn min_notional(&self) -> Option<Money> {
370 self.min_notional
371 }
372
373 fn max_price(&self) -> Option<Price> {
374 self.max_price
375 }
376
377 fn min_price(&self) -> Option<Price> {
378 self.min_price
379 }
380
381 fn margin_init(&self) -> Decimal {
382 self.margin_init
383 }
384
385 fn margin_maint(&self) -> Decimal {
386 self.margin_maint
387 }
388
389 fn maker_fee(&self) -> Decimal {
390 self.maker_fee
391 }
392
393 fn taker_fee(&self) -> Decimal {
394 self.taker_fee
395 }
396
397 fn tick_scheme(&self) -> Option<Ustr> {
398 self.tick_scheme
399 }
400
401 fn info(&self) -> Option<&Params> {
402 self.info.as_ref()
403 }
404
405 fn ts_event(&self) -> UnixNanos {
406 self.ts_event
407 }
408
409 fn ts_init(&self) -> UnixNanos {
410 self.ts_init
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use rstest::rstest;
417 use rust_decimal::Decimal;
418 use rust_decimal_macros::dec;
419
420 use crate::{
421 enums::{AssetClass, InstrumentClass},
422 identifiers::{InstrumentId, Symbol},
423 instruments::{CryptoPerpetual, Instrument, stubs::*},
424 types::{Currency, Money, Price, Quantity},
425 };
426
427 #[rstest]
428 fn test_trait_accessors(crypto_perpetual_ethusdt: CryptoPerpetual) {
429 assert_eq!(
430 crypto_perpetual_ethusdt.id(),
431 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
432 );
433 assert_eq!(
434 crypto_perpetual_ethusdt.asset_class(),
435 AssetClass::Cryptocurrency
436 );
437 assert_eq!(
438 crypto_perpetual_ethusdt.instrument_class(),
439 InstrumentClass::Swap
440 );
441 assert_eq!(
442 crypto_perpetual_ethusdt.base_currency(),
443 Some(Currency::ETH())
444 );
445 assert_eq!(crypto_perpetual_ethusdt.quote_currency(), Currency::USDT());
446 assert_eq!(
447 crypto_perpetual_ethusdt.settlement_currency(),
448 Currency::USDT()
449 );
450 assert!(!crypto_perpetual_ethusdt.is_inverse());
451 assert_eq!(crypto_perpetual_ethusdt.price_precision(), 2);
452 assert_eq!(crypto_perpetual_ethusdt.size_precision(), 3);
453 assert_eq!(
454 crypto_perpetual_ethusdt.price_increment(),
455 Price::from("0.01")
456 );
457 assert_eq!(
458 crypto_perpetual_ethusdt.size_increment(),
459 Quantity::from("0.001")
460 );
461 assert_eq!(crypto_perpetual_ethusdt.multiplier(), Quantity::from("1"));
462 assert_eq!(
463 crypto_perpetual_ethusdt.lot_size(),
464 Some(Quantity::from("1"))
465 );
466 assert_eq!(
467 crypto_perpetual_ethusdt.max_quantity(),
468 Some(Quantity::from("10000.0")),
469 );
470 assert_eq!(
471 crypto_perpetual_ethusdt.min_quantity(),
472 Some(Quantity::from("0.001")),
473 );
474 assert_eq!(
475 crypto_perpetual_ethusdt.min_notional(),
476 Some(Money::new(10.00, Currency::USDT())),
477 );
478 assert_eq!(crypto_perpetual_ethusdt.underlying(), None);
479 assert_eq!(crypto_perpetual_ethusdt.option_kind(), None);
480 assert_eq!(crypto_perpetual_ethusdt.strike_price(), None);
481 assert_eq!(crypto_perpetual_ethusdt.activation_ns(), None);
482 assert_eq!(crypto_perpetual_ethusdt.expiration_ns(), None);
483 }
484
485 #[rstest]
486 fn test_inverse_perp_accessors(xbtusd_bitmex: CryptoPerpetual) {
487 assert!(xbtusd_bitmex.is_inverse());
488 assert_eq!(xbtusd_bitmex.base_currency(), Some(Currency::BTC()));
489 assert_eq!(xbtusd_bitmex.quote_currency(), Currency::USD());
490 assert_eq!(xbtusd_bitmex.settlement_currency(), Currency::BTC());
491 assert_eq!(xbtusd_bitmex.cost_currency(), Currency::BTC());
492 }
493
494 #[rstest]
495 fn test_new_checked_price_precision_mismatch() {
496 let result = CryptoPerpetual::new_checked(
497 InstrumentId::from("TEST.EXCHANGE"),
498 Symbol::from("TEST"),
499 Currency::BTC(),
500 Currency::USDT(),
501 Currency::USDT(),
502 false,
503 3, 0,
505 Price::from("0.01"),
506 Quantity::from("1"),
507 None,
508 None,
509 None,
510 None,
511 None,
512 None,
513 None,
514 None,
515 None,
516 None,
517 None,
518 None,
519 None,
520 None,
521 0.into(),
522 0.into(),
523 );
524 assert!(result.is_err());
525 }
526
527 #[rstest]
528 fn test_new_checked_size_precision_mismatch() {
529 let result = CryptoPerpetual::new_checked(
530 InstrumentId::from("TEST.EXCHANGE"),
531 Symbol::from("TEST"),
532 Currency::BTC(),
533 Currency::USDT(),
534 Currency::USDT(),
535 false,
536 2,
537 5, Price::from("0.01"),
539 Quantity::from("1"),
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 None,
554 0.into(),
555 0.into(),
556 );
557 assert!(result.is_err());
558 }
559
560 #[rstest]
561 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
562 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
563 fn test_new_checked_rejects_non_positive_sizing(
564 #[case] multiplier: Option<Quantity>,
565 #[case] lot_size: Option<Quantity>,
566 ) {
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 2,
575 0,
576 Price::from("0.01"),
577 Quantity::from("1"),
578 multiplier,
579 lot_size,
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 let error = result.unwrap_err();
596 assert!(error.to_string().contains("not positive"), "{error}");
597 }
598
599 #[rstest]
600 fn test_serialization_roundtrip(crypto_perpetual_ethusdt: CryptoPerpetual) {
601 let json = serde_json::to_string(&crypto_perpetual_ethusdt).unwrap();
602 let deserialized: CryptoPerpetual = serde_json::from_str(&json).unwrap();
603 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
604 }
605
606 #[rstest]
607 fn test_builder_matches_new_checked() {
608 let positional = CryptoPerpetual::new_checked(
609 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
610 Symbol::from("ETHUSDT"),
611 Currency::ETH(),
612 Currency::USDT(),
613 Currency::USDT(),
614 false,
615 2,
616 3,
617 Price::from("0.01"),
618 Quantity::from("0.001"),
619 None,
620 None,
621 Some(Quantity::from("10000.0")),
622 None,
623 None,
624 None,
625 None,
626 None,
627 None,
628 None,
629 None,
630 None,
631 None,
632 None,
633 0.into(),
634 0.into(),
635 )
636 .unwrap();
637
638 let built = CryptoPerpetual::builder()
639 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
640 .raw_symbol(Symbol::from("ETHUSDT"))
641 .base_currency(Currency::ETH())
642 .quote_currency(Currency::USDT())
643 .settlement_currency(Currency::USDT())
644 .is_inverse(false)
645 .price_precision(2)
646 .size_precision(3)
647 .price_increment(Price::from("0.01"))
648 .size_increment(Quantity::from("0.001"))
649 .max_quantity(Quantity::from("10000.0"))
650 .ts_event(0.into())
651 .ts_init(0.into())
652 .build()
653 .unwrap();
654
655 assert_eq!(
656 serde_json::to_value(&positional).unwrap(),
657 serde_json::to_value(&built).unwrap(),
658 );
659 }
660
661 #[rstest]
662 fn test_builder_applies_defaults_for_omitted_optionals() {
663 let perp = CryptoPerpetual::builder()
664 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
665 .raw_symbol(Symbol::from("ETHUSDT"))
666 .base_currency(Currency::ETH())
667 .quote_currency(Currency::USDT())
668 .settlement_currency(Currency::USDT())
669 .is_inverse(false)
670 .price_precision(2)
671 .size_precision(3)
672 .price_increment(Price::from("0.01"))
673 .size_increment(Quantity::from("0.001"))
674 .ts_event(0.into())
675 .ts_init(0.into())
676 .build()
677 .unwrap();
678
679 assert_eq!(perp.multiplier, Quantity::from(1));
680 assert_eq!(perp.lot_size, Quantity::from(1));
681 assert_eq!(perp.margin_init, Decimal::default());
682 assert_eq!(perp.margin_maint, Decimal::default());
683 assert_eq!(perp.maker_fee, Decimal::default());
684 assert_eq!(perp.taker_fee, Decimal::default());
685 assert_eq!(perp.max_quantity, None);
686 assert_eq!(perp.min_notional, None);
687 assert_eq!(perp.tick_scheme, None);
688 assert_eq!(perp.info, None);
689 }
690
691 #[rstest]
692 fn test_builder_sets_optional_fields_via_value_and_maybe_setters() {
693 let perp = CryptoPerpetual::builder()
694 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
695 .raw_symbol(Symbol::from("ETHUSDT"))
696 .base_currency(Currency::ETH())
697 .quote_currency(Currency::USDT())
698 .settlement_currency(Currency::USDT())
699 .is_inverse(false)
700 .price_precision(2)
701 .size_precision(3)
702 .price_increment(Price::from("0.01"))
703 .size_increment(Quantity::from("0.001"))
704 .max_quantity(Quantity::from("10000.0"))
705 .maybe_min_notional(Some(Money::new(10.00, Currency::USDT())))
706 .maker_fee(dec!(0.0002))
707 .ts_event(0.into())
708 .ts_init(0.into())
709 .build()
710 .unwrap();
711
712 assert_eq!(perp.max_quantity, Some(Quantity::from("10000.0")));
713 assert_eq!(perp.min_notional, Some(Money::new(10.00, Currency::USDT())));
714 assert_eq!(perp.maker_fee, dec!(0.0002));
715 }
716
717 #[rstest]
718 fn test_builder_propagates_validation_error() {
719 let result = CryptoPerpetual::builder()
720 .instrument_id(InstrumentId::from("TEST.EXCHANGE"))
721 .raw_symbol(Symbol::from("TEST"))
722 .base_currency(Currency::BTC())
723 .quote_currency(Currency::USDT())
724 .settlement_currency(Currency::USDT())
725 .is_inverse(false)
726 .price_precision(3) .size_precision(0)
728 .price_increment(Price::from("0.01"))
729 .size_increment(Quantity::from("1"))
730 .ts_event(0.into())
731 .ts_init(0.into())
732 .build();
733
734 assert!(result.is_err());
735 }
736}