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