1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8, check_valid_string_ascii,
22 },
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use ustr::Ustr;
27
28use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
29use crate::{
30 enums::{AssetClass, InstrumentClass, OptionKind},
31 identifiers::{InstrumentId, Symbol},
32 types::{
33 currency::Currency,
34 money::Money,
35 price::{Price, check_positive_price},
36 quantity::{Quantity, check_positive_quantity},
37 },
38};
39
40#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.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 CryptoFuturesSpread {
53 pub id: InstrumentId,
55 pub raw_symbol: Symbol,
57 pub underlying: Currency,
59 pub quote_currency: Currency,
61 pub settlement_currency: Currency,
63 pub is_inverse: bool,
65 pub strategy_type: Ustr,
67 pub activation_ns: UnixNanos,
69 pub expiration_ns: UnixNanos,
71 pub price_precision: u8,
73 pub size_precision: u8,
75 pub price_increment: Price,
77 pub size_increment: Quantity,
79 pub multiplier: Quantity,
81 pub lot_size: Quantity,
83 pub margin_init: Decimal,
85 pub margin_maint: Decimal,
87 pub maker_fee: Decimal,
89 pub taker_fee: Decimal,
91 pub max_quantity: Option<Quantity>,
93 pub min_quantity: Option<Quantity>,
95 pub max_notional: Option<Money>,
97 pub min_notional: Option<Money>,
99 pub max_price: Option<Price>,
101 pub min_price: Option<Price>,
103 pub tick_scheme: Option<Ustr>,
105 pub info: Option<Params>,
107 pub ts_event: UnixNanos,
109 pub ts_init: UnixNanos,
111}
112
113#[bon::bon]
114impl CryptoFuturesSpread {
115 #[expect(clippy::too_many_arguments)]
125 pub fn new_checked(
126 instrument_id: InstrumentId,
127 raw_symbol: Symbol,
128 underlying: Currency,
129 quote_currency: Currency,
130 settlement_currency: Currency,
131 is_inverse: bool,
132 strategy_type: Ustr,
133 activation_ns: UnixNanos,
134 expiration_ns: UnixNanos,
135 price_precision: u8,
136 size_precision: u8,
137 price_increment: Price,
138 size_increment: Quantity,
139 multiplier: Option<Quantity>,
140 lot_size: Option<Quantity>,
141 max_quantity: Option<Quantity>,
142 min_quantity: Option<Quantity>,
143 max_notional: Option<Money>,
144 min_notional: Option<Money>,
145 max_price: Option<Price>,
146 min_price: Option<Price>,
147 margin_init: Option<Decimal>,
148 margin_maint: Option<Decimal>,
149 maker_fee: Option<Decimal>,
150 taker_fee: Option<Decimal>,
151 tick_scheme: Option<Ustr>,
152 info: Option<Params>,
153 ts_event: UnixNanos,
154 ts_init: UnixNanos,
155 ) -> CorrectnessResult<Self> {
156 check_valid_string_ascii(strategy_type.as_str(), stringify!(strategy_type))?;
157 check_equal_u8(
158 price_precision,
159 price_increment.precision,
160 stringify!(price_precision),
161 stringify!(price_increment.precision),
162 )?;
163 check_equal_u8(
164 size_precision,
165 size_increment.precision,
166 stringify!(size_precision),
167 stringify!(size_increment.precision),
168 )?;
169 check_positive_price(price_increment, stringify!(price_increment))?;
170 check_positive_quantity(size_increment, stringify!(size_increment))?;
171 check_tick_scheme(tick_scheme)?;
172
173 if let Some(multiplier) = multiplier {
174 check_positive_quantity(multiplier, stringify!(multiplier))?;
175 }
176
177 if let Some(lot_size) = lot_size {
178 check_positive_quantity(lot_size, stringify!(lot_size))?;
179 }
180
181 Ok(Self {
182 id: instrument_id,
183 raw_symbol,
184 underlying,
185 quote_currency,
186 settlement_currency,
187 is_inverse,
188 strategy_type,
189 activation_ns,
190 expiration_ns,
191 price_precision,
192 size_precision,
193 price_increment,
194 size_increment,
195 multiplier: multiplier.unwrap_or(Quantity::from(1)),
196 lot_size: lot_size.unwrap_or(Quantity::from(1)),
197 margin_init: margin_init.unwrap_or_default(),
198 margin_maint: margin_maint.unwrap_or_default(),
199 maker_fee: maker_fee.unwrap_or_default(),
200 taker_fee: taker_fee.unwrap_or_default(),
201 max_quantity,
202 min_quantity,
203 max_notional,
204 min_notional,
205 max_price,
206 min_price,
207 tick_scheme,
208 info,
209 ts_event,
210 ts_init,
211 })
212 }
213
214 #[expect(clippy::too_many_arguments)]
220 #[must_use]
221 pub fn new(
222 instrument_id: InstrumentId,
223 raw_symbol: Symbol,
224 underlying: Currency,
225 quote_currency: Currency,
226 settlement_currency: Currency,
227 is_inverse: bool,
228 strategy_type: Ustr,
229 activation_ns: UnixNanos,
230 expiration_ns: UnixNanos,
231 price_precision: u8,
232 size_precision: u8,
233 price_increment: Price,
234 size_increment: Quantity,
235 multiplier: Option<Quantity>,
236 lot_size: Option<Quantity>,
237 max_quantity: Option<Quantity>,
238 min_quantity: Option<Quantity>,
239 max_notional: Option<Money>,
240 min_notional: Option<Money>,
241 max_price: Option<Price>,
242 min_price: Option<Price>,
243 margin_init: Option<Decimal>,
244 margin_maint: Option<Decimal>,
245 maker_fee: Option<Decimal>,
246 taker_fee: Option<Decimal>,
247 tick_scheme: Option<Ustr>,
248 info: Option<Params>,
249 ts_event: UnixNanos,
250 ts_init: UnixNanos,
251 ) -> Self {
252 Self::new_checked(
253 instrument_id,
254 raw_symbol,
255 underlying,
256 quote_currency,
257 settlement_currency,
258 is_inverse,
259 strategy_type,
260 activation_ns,
261 expiration_ns,
262 price_precision,
263 size_precision,
264 price_increment,
265 size_increment,
266 multiplier,
267 lot_size,
268 max_quantity,
269 min_quantity,
270 max_notional,
271 min_notional,
272 max_price,
273 min_price,
274 margin_init,
275 margin_maint,
276 maker_fee,
277 taker_fee,
278 tick_scheme,
279 info,
280 ts_event,
281 ts_init,
282 )
283 .expect_display(FAILED)
284 }
285
286 #[builder(start_fn = builder, finish_fn = build)]
296 pub fn build_checked(
297 instrument_id: InstrumentId,
298 raw_symbol: Symbol,
299 underlying: Currency,
300 quote_currency: Currency,
301 settlement_currency: Currency,
302 is_inverse: bool,
303 strategy_type: Ustr,
304 activation_ns: UnixNanos,
305 expiration_ns: UnixNanos,
306 price_precision: u8,
307 size_precision: u8,
308 price_increment: Price,
309 size_increment: Quantity,
310 multiplier: Option<Quantity>,
311 lot_size: Option<Quantity>,
312 max_quantity: Option<Quantity>,
313 min_quantity: Option<Quantity>,
314 max_notional: Option<Money>,
315 min_notional: Option<Money>,
316 max_price: Option<Price>,
317 min_price: Option<Price>,
318 margin_init: Option<Decimal>,
319 margin_maint: Option<Decimal>,
320 maker_fee: Option<Decimal>,
321 taker_fee: Option<Decimal>,
322 tick_scheme: Option<Ustr>,
323 info: Option<Params>,
324 ts_event: UnixNanos,
325 ts_init: UnixNanos,
326 ) -> CorrectnessResult<Self> {
327 Self::new_checked(
328 instrument_id,
329 raw_symbol,
330 underlying,
331 quote_currency,
332 settlement_currency,
333 is_inverse,
334 strategy_type,
335 activation_ns,
336 expiration_ns,
337 price_precision,
338 size_precision,
339 price_increment,
340 size_increment,
341 multiplier,
342 lot_size,
343 max_quantity,
344 min_quantity,
345 max_notional,
346 min_notional,
347 max_price,
348 min_price,
349 margin_init,
350 margin_maint,
351 maker_fee,
352 taker_fee,
353 tick_scheme,
354 info,
355 ts_event,
356 ts_init,
357 )
358 }
359}
360
361impl PartialEq<Self> for CryptoFuturesSpread {
362 fn eq(&self, other: &Self) -> bool {
363 self.id == other.id
364 }
365}
366
367impl Eq for CryptoFuturesSpread {}
368
369impl Hash for CryptoFuturesSpread {
370 fn hash<H: Hasher>(&self, state: &mut H) {
371 self.id.hash(state);
372 }
373}
374
375impl Instrument for CryptoFuturesSpread {
376 fn tick_scheme(&self) -> Option<Ustr> {
377 self.tick_scheme
378 }
379 fn into_any(self) -> InstrumentAny {
380 InstrumentAny::CryptoFuturesSpread(self)
381 }
382
383 fn id(&self) -> InstrumentId {
384 self.id
385 }
386
387 fn raw_symbol(&self) -> Symbol {
388 self.raw_symbol
389 }
390
391 fn asset_class(&self) -> AssetClass {
392 AssetClass::Cryptocurrency
393 }
394
395 fn instrument_class(&self) -> InstrumentClass {
396 InstrumentClass::FuturesSpread
397 }
398
399 fn underlying(&self) -> Option<Ustr> {
400 Some(self.underlying.code)
401 }
402
403 fn base_currency(&self) -> Option<Currency> {
404 Some(self.underlying)
405 }
406
407 fn quote_currency(&self) -> Currency {
408 self.quote_currency
409 }
410
411 fn settlement_currency(&self) -> Currency {
412 self.settlement_currency
413 }
414
415 fn isin(&self) -> Option<Ustr> {
416 None
417 }
418
419 fn exchange(&self) -> Option<Ustr> {
420 None
421 }
422
423 fn option_kind(&self) -> Option<OptionKind> {
424 None
425 }
426
427 fn is_inverse(&self) -> bool {
428 self.is_inverse
429 }
430
431 fn price_precision(&self) -> u8 {
432 self.price_precision
433 }
434
435 fn size_precision(&self) -> u8 {
436 self.size_precision
437 }
438
439 fn price_increment(&self) -> Price {
440 self.price_increment
441 }
442
443 fn size_increment(&self) -> Quantity {
444 self.size_increment
445 }
446
447 fn multiplier(&self) -> Quantity {
448 self.multiplier
449 }
450
451 fn lot_size(&self) -> Option<Quantity> {
452 Some(self.lot_size)
453 }
454
455 fn max_quantity(&self) -> Option<Quantity> {
456 self.max_quantity
457 }
458
459 fn min_quantity(&self) -> Option<Quantity> {
460 self.min_quantity
461 }
462
463 fn max_price(&self) -> Option<Price> {
464 self.max_price
465 }
466
467 fn min_price(&self) -> Option<Price> {
468 self.min_price
469 }
470
471 fn ts_event(&self) -> UnixNanos {
472 self.ts_event
473 }
474
475 fn ts_init(&self) -> UnixNanos {
476 self.ts_init
477 }
478
479 fn margin_init(&self) -> Decimal {
480 self.margin_init
481 }
482
483 fn margin_maint(&self) -> Decimal {
484 self.margin_maint
485 }
486
487 fn maker_fee(&self) -> Decimal {
488 self.maker_fee
489 }
490
491 fn taker_fee(&self) -> Decimal {
492 self.taker_fee
493 }
494
495 fn strike_price(&self) -> Option<Price> {
496 None
497 }
498
499 fn strategy_type(&self) -> Option<Ustr> {
500 Some(self.strategy_type)
501 }
502
503 fn activation_ns(&self) -> Option<UnixNanos> {
504 Some(self.activation_ns)
505 }
506
507 fn expiration_ns(&self) -> Option<UnixNanos> {
508 Some(self.expiration_ns)
509 }
510
511 fn max_notional(&self) -> Option<Money> {
512 self.max_notional
513 }
514
515 fn min_notional(&self) -> Option<Money> {
516 self.min_notional
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use nautilus_core::correctness::CorrectnessResult;
523 use rstest::rstest;
524 use rust_decimal_macros::dec;
525 use ustr::Ustr;
526
527 use crate::{
528 enums::{AssetClass, InstrumentClass},
529 identifiers::{InstrumentId, Symbol},
530 instruments::{CryptoFuturesSpread, Instrument, stubs::*},
531 types::{Currency, Money, Price, Quantity},
532 };
533
534 #[rstest]
535 fn test_trait_accessors(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
536 assert_eq!(
537 crypto_futures_spread_btc_deribit.id(),
538 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT")
539 );
540 assert_eq!(
541 crypto_futures_spread_btc_deribit.asset_class(),
542 AssetClass::Cryptocurrency
543 );
544 assert_eq!(
545 crypto_futures_spread_btc_deribit.instrument_class(),
546 InstrumentClass::FuturesSpread
547 );
548 assert_eq!(
549 crypto_futures_spread_btc_deribit.quote_currency(),
550 Currency::USD()
551 );
552 assert_eq!(
553 crypto_futures_spread_btc_deribit.settlement_currency(),
554 Currency::BTC()
555 );
556 assert!(!crypto_futures_spread_btc_deribit.is_inverse());
557 assert_eq!(crypto_futures_spread_btc_deribit.price_precision(), 1);
558 assert_eq!(crypto_futures_spread_btc_deribit.size_precision(), 0);
559 assert_eq!(
560 crypto_futures_spread_btc_deribit.size_increment(),
561 Quantity::from("1")
562 );
563 assert!(crypto_futures_spread_btc_deribit.activation_ns().is_some());
564 assert!(crypto_futures_spread_btc_deribit.expiration_ns().is_some());
565 }
566
567 #[rstest]
568 fn test_new_checked_price_precision_mismatch() {
569 let result = CryptoFuturesSpread::new_checked(
570 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
571 Symbol::from("BTC-FS-TEST"),
572 Currency::BTC(),
573 Currency::USD(),
574 Currency::BTC(),
575 false,
576 ustr::Ustr::from("FS"),
577 0.into(),
578 0.into(),
579 4, 0,
581 Price::from("0.5"),
582 Quantity::from("1"),
583 None,
584 None,
585 None,
586 None,
587 None,
588 None,
589 None,
590 None,
591 None,
592 None,
593 None,
594 None,
595 None,
596 None,
597 0.into(),
598 0.into(),
599 );
600 assert!(result.is_err());
601 }
602
603 #[rstest]
604 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
605 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
606 fn test_new_checked_rejects_non_positive_sizing(
607 #[case] multiplier: Option<Quantity>,
608 #[case] lot_size: Option<Quantity>,
609 ) {
610 let result = crypto_futures_spread_result(multiplier, lot_size);
611 assert!(result.is_err());
612 }
613
614 #[rstest]
615 fn test_serialization_roundtrip(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
616 let json = serde_json::to_string(&crypto_futures_spread_btc_deribit).unwrap();
617 let deserialized: CryptoFuturesSpread = serde_json::from_str(&json).unwrap();
618 assert_eq!(crypto_futures_spread_btc_deribit, deserialized);
619 }
620
621 #[rstest]
622 fn test_builder_matches_new_checked() {
623 let positional = CryptoFuturesSpread::new_checked(
624 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"),
625 Symbol::from("BTC-FS-19MAY26_PERP"),
626 Currency::BTC(),
627 Currency::USD(),
628 Currency::USDC(),
629 false,
630 Ustr::from("FS"),
631 1.into(),
632 2.into(),
633 1,
634 0,
635 Price::from("0.5"),
636 Quantity::from("1"),
637 Some(Quantity::from("10")),
638 Some(Quantity::from("1")),
639 Some(Quantity::from("100")),
640 Some(Quantity::from("1")),
641 Some(Money::new(5_000_000.0, Currency::USD())),
642 Some(Money::new(10.0, Currency::USD())),
643 Some(Price::from("1000000.0")),
644 Some(Price::from("0.5")),
645 Some(dec!(0.01)),
646 Some(dec!(0.02)),
647 Some(dec!(0.0002)),
648 Some(dec!(0.0004)),
649 None,
650 None,
651 10.into(),
652 20.into(),
653 )
654 .unwrap();
655
656 let built = CryptoFuturesSpread::builder()
657 .instrument_id(InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"))
658 .raw_symbol(Symbol::from("BTC-FS-19MAY26_PERP"))
659 .underlying(Currency::BTC())
660 .quote_currency(Currency::USD())
661 .settlement_currency(Currency::USDC())
662 .is_inverse(false)
663 .strategy_type(Ustr::from("FS"))
664 .activation_ns(1.into())
665 .expiration_ns(2.into())
666 .price_precision(1)
667 .size_precision(0)
668 .price_increment(Price::from("0.5"))
669 .size_increment(Quantity::from("1"))
670 .multiplier(Quantity::from("10"))
671 .lot_size(Quantity::from("1"))
672 .max_quantity(Quantity::from("100"))
673 .min_quantity(Quantity::from("1"))
674 .max_notional(Money::new(5_000_000.0, Currency::USD()))
675 .min_notional(Money::new(10.0, Currency::USD()))
676 .max_price(Price::from("1000000.0"))
677 .min_price(Price::from("0.5"))
678 .margin_init(dec!(0.01))
679 .margin_maint(dec!(0.02))
680 .maker_fee(dec!(0.0002))
681 .taker_fee(dec!(0.0004))
682 .ts_event(10.into())
683 .ts_init(20.into())
684 .build()
685 .unwrap();
686
687 assert_eq!(
688 serde_json::to_value(&positional).unwrap(),
689 serde_json::to_value(&built).unwrap(),
690 );
691 }
692
693 fn crypto_futures_spread_result(
694 multiplier: Option<Quantity>,
695 lot_size: Option<Quantity>,
696 ) -> CorrectnessResult<CryptoFuturesSpread> {
697 CryptoFuturesSpread::new_checked(
698 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
699 Symbol::from("BTC-FS-TEST"),
700 Currency::BTC(),
701 Currency::USD(),
702 Currency::BTC(),
703 false,
704 ustr::Ustr::from("FS"),
705 0.into(),
706 0.into(),
707 1,
708 0,
709 Price::from("0.5"),
710 Quantity::from("1"),
711 multiplier,
712 lot_size,
713 None,
714 None,
715 None,
716 None,
717 None,
718 None,
719 None,
720 None,
721 None,
722 None,
723 None,
724 None,
725 0.into(),
726 0.into(),
727 )
728 }
729}