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 tick_scheme(&self) -> Option<Ustr> {
274 self.tick_scheme
275 }
276 fn into_any(self) -> InstrumentAny {
277 InstrumentAny::CryptoPerpetual(self)
278 }
279
280 fn id(&self) -> InstrumentId {
281 self.id
282 }
283
284 fn raw_symbol(&self) -> Symbol {
285 self.raw_symbol
286 }
287
288 fn asset_class(&self) -> AssetClass {
289 AssetClass::Cryptocurrency
290 }
291
292 fn instrument_class(&self) -> InstrumentClass {
293 InstrumentClass::Swap
294 }
295 fn underlying(&self) -> Option<Ustr> {
296 None
297 }
298
299 fn base_currency(&self) -> Option<Currency> {
300 Some(self.base_currency)
301 }
302
303 fn quote_currency(&self) -> Currency {
304 self.quote_currency
305 }
306
307 fn settlement_currency(&self) -> Currency {
308 self.settlement_currency
309 }
310
311 fn isin(&self) -> Option<Ustr> {
312 None
313 }
314 fn option_kind(&self) -> Option<OptionKind> {
315 None
316 }
317 fn exchange(&self) -> Option<Ustr> {
318 None
319 }
320 fn strike_price(&self) -> Option<Price> {
321 None
322 }
323
324 fn activation_ns(&self) -> Option<UnixNanos> {
325 None
326 }
327
328 fn expiration_ns(&self) -> Option<UnixNanos> {
329 None
330 }
331
332 fn is_inverse(&self) -> bool {
333 self.is_inverse
334 }
335
336 fn price_precision(&self) -> u8 {
337 self.price_precision
338 }
339
340 fn size_precision(&self) -> u8 {
341 self.size_precision
342 }
343
344 fn price_increment(&self) -> Price {
345 self.price_increment
346 }
347
348 fn size_increment(&self) -> Quantity {
349 self.size_increment
350 }
351
352 fn multiplier(&self) -> Quantity {
353 self.multiplier
354 }
355
356 fn lot_size(&self) -> Option<Quantity> {
357 Some(self.lot_size)
358 }
359
360 fn max_quantity(&self) -> Option<Quantity> {
361 self.max_quantity
362 }
363
364 fn min_quantity(&self) -> Option<Quantity> {
365 self.min_quantity
366 }
367
368 fn max_notional(&self) -> Option<Money> {
369 self.max_notional
370 }
371
372 fn min_notional(&self) -> Option<Money> {
373 self.min_notional
374 }
375
376 fn max_price(&self) -> Option<Price> {
377 self.max_price
378 }
379
380 fn min_price(&self) -> Option<Price> {
381 self.min_price
382 }
383
384 fn margin_init(&self) -> Decimal {
385 self.margin_init
386 }
387
388 fn margin_maint(&self) -> Decimal {
389 self.margin_maint
390 }
391
392 fn maker_fee(&self) -> Decimal {
393 self.maker_fee
394 }
395
396 fn taker_fee(&self) -> Decimal {
397 self.taker_fee
398 }
399
400 fn ts_event(&self) -> UnixNanos {
401 self.ts_event
402 }
403
404 fn ts_init(&self) -> UnixNanos {
405 self.ts_init
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use rstest::rstest;
412 use rust_decimal::Decimal;
413 use rust_decimal_macros::dec;
414
415 use crate::{
416 enums::{AssetClass, InstrumentClass},
417 identifiers::{InstrumentId, Symbol},
418 instruments::{CryptoPerpetual, Instrument, stubs::*},
419 types::{Currency, Money, Price, Quantity},
420 };
421
422 #[rstest]
423 fn test_trait_accessors(crypto_perpetual_ethusdt: CryptoPerpetual) {
424 assert_eq!(
425 crypto_perpetual_ethusdt.id(),
426 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
427 );
428 assert_eq!(
429 crypto_perpetual_ethusdt.asset_class(),
430 AssetClass::Cryptocurrency
431 );
432 assert_eq!(
433 crypto_perpetual_ethusdt.instrument_class(),
434 InstrumentClass::Swap
435 );
436 assert_eq!(
437 crypto_perpetual_ethusdt.base_currency(),
438 Some(Currency::ETH())
439 );
440 assert_eq!(crypto_perpetual_ethusdt.quote_currency(), Currency::USDT());
441 assert_eq!(
442 crypto_perpetual_ethusdt.settlement_currency(),
443 Currency::USDT()
444 );
445 assert!(!crypto_perpetual_ethusdt.is_inverse());
446 assert_eq!(crypto_perpetual_ethusdt.price_precision(), 2);
447 assert_eq!(crypto_perpetual_ethusdt.size_precision(), 3);
448 assert_eq!(
449 crypto_perpetual_ethusdt.price_increment(),
450 Price::from("0.01")
451 );
452 assert_eq!(
453 crypto_perpetual_ethusdt.size_increment(),
454 Quantity::from("0.001")
455 );
456 assert_eq!(crypto_perpetual_ethusdt.multiplier(), Quantity::from("1"));
457 assert_eq!(
458 crypto_perpetual_ethusdt.lot_size(),
459 Some(Quantity::from("1"))
460 );
461 assert_eq!(
462 crypto_perpetual_ethusdt.max_quantity(),
463 Some(Quantity::from("10000.0")),
464 );
465 assert_eq!(
466 crypto_perpetual_ethusdt.min_quantity(),
467 Some(Quantity::from("0.001")),
468 );
469 assert_eq!(
470 crypto_perpetual_ethusdt.min_notional(),
471 Some(Money::new(10.00, Currency::USDT())),
472 );
473 assert_eq!(crypto_perpetual_ethusdt.underlying(), None);
474 assert_eq!(crypto_perpetual_ethusdt.option_kind(), None);
475 assert_eq!(crypto_perpetual_ethusdt.strike_price(), None);
476 assert_eq!(crypto_perpetual_ethusdt.activation_ns(), None);
477 assert_eq!(crypto_perpetual_ethusdt.expiration_ns(), None);
478 }
479
480 #[rstest]
481 fn test_inverse_perp_accessors(xbtusd_bitmex: CryptoPerpetual) {
482 assert!(xbtusd_bitmex.is_inverse());
483 assert_eq!(xbtusd_bitmex.base_currency(), Some(Currency::BTC()));
484 assert_eq!(xbtusd_bitmex.quote_currency(), Currency::USD());
485 assert_eq!(xbtusd_bitmex.settlement_currency(), Currency::BTC());
486 assert_eq!(xbtusd_bitmex.cost_currency(), Currency::BTC());
487 }
488
489 #[rstest]
490 fn test_new_checked_price_precision_mismatch() {
491 let result = CryptoPerpetual::new_checked(
492 InstrumentId::from("TEST.EXCHANGE"),
493 Symbol::from("TEST"),
494 Currency::BTC(),
495 Currency::USDT(),
496 Currency::USDT(),
497 false,
498 3, 0,
500 Price::from("0.01"),
501 Quantity::from("1"),
502 None,
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 0.into(),
517 0.into(),
518 );
519 assert!(result.is_err());
520 }
521
522 #[rstest]
523 fn test_new_checked_size_precision_mismatch() {
524 let result = CryptoPerpetual::new_checked(
525 InstrumentId::from("TEST.EXCHANGE"),
526 Symbol::from("TEST"),
527 Currency::BTC(),
528 Currency::USDT(),
529 Currency::USDT(),
530 false,
531 2,
532 5, Price::from("0.01"),
534 Quantity::from("1"),
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 None,
549 0.into(),
550 0.into(),
551 );
552 assert!(result.is_err());
553 }
554
555 #[rstest]
556 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
557 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
558 fn test_new_checked_rejects_non_positive_sizing(
559 #[case] multiplier: Option<Quantity>,
560 #[case] lot_size: Option<Quantity>,
561 ) {
562 let result = CryptoPerpetual::new_checked(
563 InstrumentId::from("TEST.EXCHANGE"),
564 Symbol::from("TEST"),
565 Currency::BTC(),
566 Currency::USDT(),
567 Currency::USDT(),
568 false,
569 2,
570 0,
571 Price::from("0.01"),
572 Quantity::from("1"),
573 multiplier,
574 lot_size,
575 None,
576 None,
577 None,
578 None,
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 0.into(),
588 0.into(),
589 );
590 let error = result.unwrap_err();
591 assert!(error.to_string().contains("not positive"), "{error}");
592 }
593
594 #[rstest]
595 fn test_serialization_roundtrip(crypto_perpetual_ethusdt: CryptoPerpetual) {
596 let json = serde_json::to_string(&crypto_perpetual_ethusdt).unwrap();
597 let deserialized: CryptoPerpetual = serde_json::from_str(&json).unwrap();
598 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
599 }
600
601 #[rstest]
602 fn test_builder_matches_new_checked() {
603 let positional = CryptoPerpetual::new_checked(
604 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
605 Symbol::from("ETHUSDT"),
606 Currency::ETH(),
607 Currency::USDT(),
608 Currency::USDT(),
609 false,
610 2,
611 3,
612 Price::from("0.01"),
613 Quantity::from("0.001"),
614 None,
615 None,
616 Some(Quantity::from("10000.0")),
617 None,
618 None,
619 None,
620 None,
621 None,
622 None,
623 None,
624 None,
625 None,
626 None,
627 None,
628 0.into(),
629 0.into(),
630 )
631 .unwrap();
632
633 let built = CryptoPerpetual::builder()
634 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
635 .raw_symbol(Symbol::from("ETHUSDT"))
636 .base_currency(Currency::ETH())
637 .quote_currency(Currency::USDT())
638 .settlement_currency(Currency::USDT())
639 .is_inverse(false)
640 .price_precision(2)
641 .size_precision(3)
642 .price_increment(Price::from("0.01"))
643 .size_increment(Quantity::from("0.001"))
644 .max_quantity(Quantity::from("10000.0"))
645 .ts_event(0.into())
646 .ts_init(0.into())
647 .build()
648 .unwrap();
649
650 assert_eq!(
651 serde_json::to_value(&positional).unwrap(),
652 serde_json::to_value(&built).unwrap(),
653 );
654 }
655
656 #[rstest]
657 fn test_builder_applies_defaults_for_omitted_optionals() {
658 let perp = CryptoPerpetual::builder()
659 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
660 .raw_symbol(Symbol::from("ETHUSDT"))
661 .base_currency(Currency::ETH())
662 .quote_currency(Currency::USDT())
663 .settlement_currency(Currency::USDT())
664 .is_inverse(false)
665 .price_precision(2)
666 .size_precision(3)
667 .price_increment(Price::from("0.01"))
668 .size_increment(Quantity::from("0.001"))
669 .ts_event(0.into())
670 .ts_init(0.into())
671 .build()
672 .unwrap();
673
674 assert_eq!(perp.multiplier, Quantity::from(1));
675 assert_eq!(perp.lot_size, Quantity::from(1));
676 assert_eq!(perp.margin_init, Decimal::default());
677 assert_eq!(perp.margin_maint, Decimal::default());
678 assert_eq!(perp.maker_fee, Decimal::default());
679 assert_eq!(perp.taker_fee, Decimal::default());
680 assert_eq!(perp.max_quantity, None);
681 assert_eq!(perp.min_notional, None);
682 assert_eq!(perp.tick_scheme, None);
683 assert_eq!(perp.info, None);
684 }
685
686 #[rstest]
687 fn test_builder_sets_optional_fields_via_value_and_maybe_setters() {
688 let perp = CryptoPerpetual::builder()
689 .instrument_id(InstrumentId::from("ETHUSDT-PERP.BINANCE"))
690 .raw_symbol(Symbol::from("ETHUSDT"))
691 .base_currency(Currency::ETH())
692 .quote_currency(Currency::USDT())
693 .settlement_currency(Currency::USDT())
694 .is_inverse(false)
695 .price_precision(2)
696 .size_precision(3)
697 .price_increment(Price::from("0.01"))
698 .size_increment(Quantity::from("0.001"))
699 .max_quantity(Quantity::from("10000.0"))
700 .maybe_min_notional(Some(Money::new(10.00, Currency::USDT())))
701 .maker_fee(dec!(0.0002))
702 .ts_event(0.into())
703 .ts_init(0.into())
704 .build()
705 .unwrap();
706
707 assert_eq!(perp.max_quantity, Some(Quantity::from("10000.0")));
708 assert_eq!(perp.min_notional, Some(Money::new(10.00, Currency::USDT())));
709 assert_eq!(perp.maker_fee, dec!(0.0002));
710 }
711
712 #[rstest]
713 fn test_builder_propagates_validation_error() {
714 let result = CryptoPerpetual::builder()
715 .instrument_id(InstrumentId::from("TEST.EXCHANGE"))
716 .raw_symbol(Symbol::from("TEST"))
717 .base_currency(Currency::BTC())
718 .quote_currency(Currency::USDT())
719 .settlement_currency(Currency::USDT())
720 .is_inverse(false)
721 .price_precision(3) .size_precision(0)
723 .price_increment(Price::from("0.01"))
724 .size_increment(Quantity::from("1"))
725 .ts_event(0.into())
726 .ts_init(0.into())
727 .build();
728
729 assert!(result.is_err());
730 }
731}