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 CryptoOptionSpread {
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 CryptoOptionSpread {
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_notional,
202 min_notional,
203 max_quantity,
204 min_quantity,
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 CryptoOptionSpread {
362 fn eq(&self, other: &Self) -> bool {
363 self.id == other.id
364 }
365}
366
367impl Eq for CryptoOptionSpread {}
368
369impl Hash for CryptoOptionSpread {
370 fn hash<H: Hasher>(&self, state: &mut H) {
371 self.id.hash(state);
372 }
373}
374
375impl Instrument for CryptoOptionSpread {
376 fn tick_scheme(&self) -> Option<Ustr> {
377 self.tick_scheme
378 }
379 fn into_any(self) -> InstrumentAny {
380 InstrumentAny::CryptoOptionSpread(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::OptionSpread
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 is_inverse(&self) -> bool {
416 self.is_inverse
417 }
418
419 fn isin(&self) -> Option<Ustr> {
420 None
421 }
422
423 fn option_kind(&self) -> Option<OptionKind> {
424 None
425 }
426
427 fn strike_price(&self) -> Option<Price> {
428 None
429 }
430
431 fn strategy_type(&self) -> Option<Ustr> {
432 Some(self.strategy_type)
433 }
434
435 fn activation_ns(&self) -> Option<UnixNanos> {
436 Some(self.activation_ns)
437 }
438
439 fn expiration_ns(&self) -> Option<UnixNanos> {
440 Some(self.expiration_ns)
441 }
442
443 fn exchange(&self) -> Option<Ustr> {
444 None
445 }
446
447 fn price_precision(&self) -> u8 {
448 self.price_precision
449 }
450
451 fn size_precision(&self) -> u8 {
452 self.size_precision
453 }
454
455 fn price_increment(&self) -> Price {
456 self.price_increment
457 }
458
459 fn size_increment(&self) -> Quantity {
460 self.size_increment
461 }
462
463 fn multiplier(&self) -> Quantity {
464 self.multiplier
465 }
466
467 fn lot_size(&self) -> Option<Quantity> {
468 Some(self.lot_size)
469 }
470
471 fn max_quantity(&self) -> Option<Quantity> {
472 self.max_quantity
473 }
474
475 fn min_quantity(&self) -> Option<Quantity> {
476 self.min_quantity
477 }
478
479 fn max_notional(&self) -> Option<Money> {
480 self.max_notional
481 }
482
483 fn min_notional(&self) -> Option<Money> {
484 self.min_notional
485 }
486
487 fn max_price(&self) -> Option<Price> {
488 self.max_price
489 }
490
491 fn min_price(&self) -> Option<Price> {
492 self.min_price
493 }
494
495 fn ts_event(&self) -> UnixNanos {
496 self.ts_event
497 }
498
499 fn ts_init(&self) -> UnixNanos {
500 self.ts_init
501 }
502
503 fn margin_init(&self) -> Decimal {
504 self.margin_init
505 }
506
507 fn margin_maint(&self) -> Decimal {
508 self.margin_maint
509 }
510
511 fn maker_fee(&self) -> Decimal {
512 self.maker_fee
513 }
514
515 fn taker_fee(&self) -> Decimal {
516 self.taker_fee
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
526 use crate::{
527 enums::{AssetClass, InstrumentClass},
528 identifiers::{InstrumentId, Symbol},
529 instruments::{CryptoOptionSpread, Instrument, stubs::*},
530 types::{Currency, Money, Price, Quantity},
531 };
532
533 #[rstest]
534 fn test_trait_accessors(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
535 assert_eq!(
536 crypto_option_spread_btc_deribit.id(),
537 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT")
538 );
539 assert_eq!(
540 crypto_option_spread_btc_deribit.asset_class(),
541 AssetClass::Cryptocurrency
542 );
543 assert_eq!(
544 crypto_option_spread_btc_deribit.instrument_class(),
545 InstrumentClass::OptionSpread
546 );
547 assert_eq!(
548 crypto_option_spread_btc_deribit.quote_currency(),
549 Currency::USD()
550 );
551 assert_eq!(
552 crypto_option_spread_btc_deribit.settlement_currency(),
553 Currency::BTC()
554 );
555 assert!(!crypto_option_spread_btc_deribit.is_inverse());
556 assert_eq!(crypto_option_spread_btc_deribit.price_precision(), 4);
557 assert_eq!(crypto_option_spread_btc_deribit.size_precision(), 1);
558 assert_eq!(
559 crypto_option_spread_btc_deribit.size_increment(),
560 Quantity::from("0.1")
561 );
562 assert!(crypto_option_spread_btc_deribit.activation_ns().is_some());
563 assert!(crypto_option_spread_btc_deribit.expiration_ns().is_some());
564 }
565
566 #[rstest]
567 fn test_new_checked_price_precision_mismatch() {
568 let result = CryptoOptionSpread::new_checked(
569 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
570 Symbol::from("BTC-CS-TEST"),
571 Currency::BTC(),
572 Currency::USD(),
573 Currency::BTC(),
574 false,
575 ustr::Ustr::from("CS"),
576 0.into(),
577 0.into(),
578 4, 1,
580 Price::from("0.001"),
581 Quantity::from("0.1"),
582 None,
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 0.into(),
597 0.into(),
598 );
599 assert!(result.is_err());
600 }
601
602 #[rstest]
603 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
604 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
605 fn test_new_checked_rejects_non_positive_sizing(
606 #[case] multiplier: Option<Quantity>,
607 #[case] lot_size: Option<Quantity>,
608 ) {
609 let result = crypto_option_spread_result(multiplier, lot_size);
610 assert!(result.is_err());
611 }
612
613 #[rstest]
614 fn test_serialization_roundtrip(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
615 let json = serde_json::to_string(&crypto_option_spread_btc_deribit).unwrap();
616 let deserialized: CryptoOptionSpread = serde_json::from_str(&json).unwrap();
617 assert_eq!(crypto_option_spread_btc_deribit, deserialized);
618 }
619
620 #[rstest]
621 fn test_builder_matches_new_checked() {
622 let positional = CryptoOptionSpread::new_checked(
623 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"),
624 Symbol::from("BTC-CS-19MAY26-70000_75000"),
625 Currency::BTC(),
626 Currency::USDC(),
627 Currency::USDT(),
628 false,
629 ustr::Ustr::from("CS"),
630 1.into(),
631 2.into(),
632 4,
633 1,
634 Price::from("0.0001"),
635 Quantity::from("0.1"),
636 Some(Quantity::from("10")),
637 Some(Quantity::from("5")),
638 Some(Quantity::from("1000.0")),
639 Some(Quantity::from("0.1")),
640 Some(Money::new(1_000_000.0, Currency::USDC())),
641 Some(Money::new(10.0, Currency::USDC())),
642 Some(Price::from("9.9999")),
643 Some(Price::from("0.0001")),
644 Some(dec!(0.01)),
645 Some(dec!(0.02)),
646 Some(dec!(0.0002)),
647 Some(dec!(0.0004)),
648 None,
649 None,
650 1.into(),
651 2.into(),
652 )
653 .unwrap();
654
655 let built = CryptoOptionSpread::builder()
656 .instrument_id(InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"))
657 .raw_symbol(Symbol::from("BTC-CS-19MAY26-70000_75000"))
658 .underlying(Currency::BTC())
659 .quote_currency(Currency::USDC())
660 .settlement_currency(Currency::USDT())
661 .is_inverse(false)
662 .strategy_type(ustr::Ustr::from("CS"))
663 .activation_ns(1.into())
664 .expiration_ns(2.into())
665 .price_precision(4)
666 .size_precision(1)
667 .price_increment(Price::from("0.0001"))
668 .size_increment(Quantity::from("0.1"))
669 .multiplier(Quantity::from("10"))
670 .lot_size(Quantity::from("5"))
671 .max_quantity(Quantity::from("1000.0"))
672 .min_quantity(Quantity::from("0.1"))
673 .max_notional(Money::new(1_000_000.0, Currency::USDC()))
674 .min_notional(Money::new(10.0, Currency::USDC()))
675 .max_price(Price::from("9.9999"))
676 .min_price(Price::from("0.0001"))
677 .margin_init(dec!(0.01))
678 .margin_maint(dec!(0.02))
679 .maker_fee(dec!(0.0002))
680 .taker_fee(dec!(0.0004))
681 .ts_event(1.into())
682 .ts_init(2.into())
683 .build()
684 .unwrap();
685
686 assert_eq!(
687 serde_json::to_value(&positional).unwrap(),
688 serde_json::to_value(&built).unwrap(),
689 );
690 }
691
692 fn crypto_option_spread_result(
693 multiplier: Option<Quantity>,
694 lot_size: Option<Quantity>,
695 ) -> CorrectnessResult<CryptoOptionSpread> {
696 CryptoOptionSpread::new_checked(
697 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
698 Symbol::from("BTC-CS-TEST"),
699 Currency::BTC(),
700 Currency::USD(),
701 Currency::BTC(),
702 false,
703 ustr::Ustr::from("CS"),
704 0.into(),
705 0.into(),
706 4,
707 1,
708 Price::from("0.0001"),
709 Quantity::from("0.1"),
710 multiplier,
711 lot_size,
712 None,
713 None,
714 None,
715 None,
716 None,
717 None,
718 None,
719 None,
720 None,
721 None,
722 None,
723 None,
724 0.into(),
725 0.into(),
726 )
727 }
728}