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::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
27use crate::{
28 enums::{AssetClass, InstrumentClass, OptionKind},
29 identifiers::{InstrumentId, Symbol},
30 types::{
31 currency::Currency,
32 money::Money,
33 price::{Price, check_positive_price},
34 quantity::{Quantity, check_positive_quantity},
35 },
36};
37
38#[repr(C)]
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
48)]
49pub struct CryptoOption {
50 pub id: InstrumentId,
52 pub raw_symbol: Symbol,
54 pub underlying: Currency,
56 pub quote_currency: Currency,
58 pub settlement_currency: Currency,
60 pub is_inverse: bool,
62 pub option_kind: OptionKind,
64 pub strike_price: Price,
66 pub activation_ns: UnixNanos,
68 pub expiration_ns: UnixNanos,
70 pub price_precision: u8,
72 pub size_precision: u8,
74 pub price_increment: Price,
76 pub size_increment: Quantity,
78 pub multiplier: Quantity,
80 pub lot_size: Quantity,
82 pub margin_init: Decimal,
84 pub margin_maint: Decimal,
86 pub max_quantity: Option<Quantity>,
88 pub min_quantity: Option<Quantity>,
90 pub max_notional: Option<Money>,
92 pub min_notional: Option<Money>,
94 pub max_price: Option<Price>,
96 pub min_price: Option<Price>,
98 pub tick_scheme: Option<Ustr>,
100 pub info: Option<Params>,
102 pub ts_event: UnixNanos,
104 pub ts_init: UnixNanos,
106}
107
108#[bon::bon]
109impl CryptoOption {
110 #[expect(clippy::too_many_arguments)]
111 fn new_checked(
112 instrument_id: InstrumentId,
113 raw_symbol: Symbol,
114 underlying: Currency,
115 quote_currency: Currency,
116 settlement_currency: Currency,
117 is_inverse: bool,
118 option_kind: OptionKind,
119 strike_price: Price,
120 activation_ns: UnixNanos,
121 expiration_ns: UnixNanos,
122 price_precision: u8,
123 size_precision: u8,
124 price_increment: Price,
125 size_increment: Quantity,
126 multiplier: Option<Quantity>,
127 lot_size: Option<Quantity>,
128 max_quantity: Option<Quantity>,
129 min_quantity: Option<Quantity>,
130 max_notional: Option<Money>,
131 min_notional: Option<Money>,
132 max_price: Option<Price>,
133 min_price: Option<Price>,
134 margin_init: Option<Decimal>,
135 margin_maint: Option<Decimal>,
136 tick_scheme: Option<Ustr>,
137 info: Option<Params>,
138 ts_event: UnixNanos,
139 ts_init: UnixNanos,
140 ) -> CorrectnessResult<Self> {
141 check_equal_u8(
142 price_precision,
143 price_increment.precision,
144 stringify!(price_precision),
145 stringify!(price_increment.precision),
146 )?;
147 check_equal_u8(
148 size_precision,
149 size_increment.precision,
150 stringify!(size_precision),
151 stringify!(size_increment.precision),
152 )?;
153 check_positive_price(price_increment, stringify!(price_increment))?;
154 check_positive_price(strike_price, stringify!(strike_price))?;
155 check_positive_quantity(size_increment, stringify!(size_increment))?;
156 check_tick_scheme(tick_scheme)?;
157
158 if let Some(multiplier) = multiplier {
159 check_positive_quantity(multiplier, stringify!(multiplier))?;
160 }
161
162 if let Some(lot_size) = lot_size {
163 check_positive_quantity(lot_size, stringify!(lot_size))?;
164 }
165
166 Ok(Self {
167 id: instrument_id,
168 raw_symbol,
169 underlying,
170 quote_currency,
171 settlement_currency,
172 is_inverse,
173 option_kind,
174 strike_price,
175 activation_ns,
176 expiration_ns,
177 price_precision,
178 size_precision,
179 price_increment,
180 size_increment,
181 multiplier: multiplier.unwrap_or(Quantity::from(1)),
182 lot_size: lot_size.unwrap_or(Quantity::from(1)),
183 margin_init: margin_init.unwrap_or_default(),
184 margin_maint: margin_maint.unwrap_or_default(),
185 max_notional,
186 min_notional,
187 max_quantity,
188 min_quantity,
189 max_price,
190 min_price,
191 tick_scheme,
192 info,
193 ts_event,
194 ts_init,
195 })
196 }
197
198 #[builder(start_fn = builder, finish_fn = build)]
207 pub fn build_checked(
208 instrument_id: InstrumentId,
209 raw_symbol: Symbol,
210 underlying: Currency,
211 quote_currency: Currency,
212 settlement_currency: Currency,
213 is_inverse: bool,
214 option_kind: OptionKind,
215 strike_price: Price,
216 activation_ns: UnixNanos,
217 expiration_ns: UnixNanos,
218 price_precision: u8,
219 size_precision: u8,
220 price_increment: Price,
221 size_increment: Quantity,
222 multiplier: Option<Quantity>,
223 lot_size: Option<Quantity>,
224 max_quantity: Option<Quantity>,
225 min_quantity: Option<Quantity>,
226 max_notional: Option<Money>,
227 min_notional: Option<Money>,
228 max_price: Option<Price>,
229 min_price: Option<Price>,
230 margin_init: Option<Decimal>,
231 margin_maint: Option<Decimal>,
232 tick_scheme: Option<Ustr>,
233 info: Option<Params>,
234 ts_event: UnixNanos,
235 ts_init: UnixNanos,
236 ) -> CorrectnessResult<Self> {
237 Self::new_checked(
238 instrument_id,
239 raw_symbol,
240 underlying,
241 quote_currency,
242 settlement_currency,
243 is_inverse,
244 option_kind,
245 strike_price,
246 activation_ns,
247 expiration_ns,
248 price_precision,
249 size_precision,
250 price_increment,
251 size_increment,
252 multiplier,
253 lot_size,
254 max_quantity,
255 min_quantity,
256 max_notional,
257 min_notional,
258 max_price,
259 min_price,
260 margin_init,
261 margin_maint,
262 tick_scheme,
263 info,
264 ts_event,
265 ts_init,
266 )
267 }
268}
269
270impl PartialEq<Self> for CryptoOption {
271 fn eq(&self, other: &Self) -> bool {
272 self.id == other.id
273 }
274}
275
276impl Eq for CryptoOption {}
277
278impl Hash for CryptoOption {
279 fn hash<H: Hasher>(&self, state: &mut H) {
280 self.id.hash(state);
281 }
282}
283
284impl Instrument for CryptoOption {
285 fn into_any(self) -> InstrumentAny {
286 InstrumentAny::CryptoOption(self)
287 }
288
289 fn id(&self) -> InstrumentId {
290 self.id
291 }
292
293 fn raw_symbol(&self) -> Symbol {
294 self.raw_symbol
295 }
296
297 fn asset_class(&self) -> AssetClass {
298 AssetClass::Cryptocurrency
299 }
300
301 fn instrument_class(&self) -> InstrumentClass {
302 InstrumentClass::Option
303 }
304
305 fn underlying(&self) -> Option<Ustr> {
306 Some(self.underlying.code)
307 }
308
309 fn base_currency(&self) -> Option<Currency> {
310 Some(self.underlying)
311 }
312
313 fn quote_currency(&self) -> Currency {
314 self.quote_currency
315 }
316
317 fn settlement_currency(&self) -> Currency {
318 self.settlement_currency
319 }
320
321 fn is_inverse(&self) -> bool {
322 self.is_inverse
323 }
324
325 fn isin(&self) -> Option<Ustr> {
326 None }
328
329 fn option_kind(&self) -> Option<OptionKind> {
330 Some(self.option_kind)
331 }
332
333 fn strike_price(&self) -> Option<Price> {
334 Some(self.strike_price)
335 }
336
337 fn activation_ns(&self) -> Option<UnixNanos> {
338 Some(self.activation_ns)
339 }
340
341 fn expiration_ns(&self) -> Option<UnixNanos> {
342 Some(self.expiration_ns)
343 }
344
345 fn exchange(&self) -> Option<Ustr> {
346 None }
348
349 fn price_precision(&self) -> u8 {
350 self.price_precision
351 }
352
353 fn size_precision(&self) -> u8 {
354 self.size_precision
355 }
356
357 fn price_increment(&self) -> Price {
358 self.price_increment
359 }
360
361 fn size_increment(&self) -> Quantity {
362 self.size_increment
363 }
364
365 fn multiplier(&self) -> Quantity {
366 self.multiplier
367 }
368
369 fn lot_size(&self) -> Option<Quantity> {
370 Some(self.lot_size)
371 }
372
373 fn max_quantity(&self) -> Option<Quantity> {
374 self.max_quantity
375 }
376
377 fn min_quantity(&self) -> Option<Quantity> {
378 self.min_quantity
379 }
380
381 fn max_notional(&self) -> Option<Money> {
382 self.max_notional
383 }
384
385 fn min_notional(&self) -> Option<Money> {
386 self.min_notional
387 }
388
389 fn max_price(&self) -> Option<Price> {
390 self.max_price
391 }
392
393 fn min_price(&self) -> Option<Price> {
394 self.min_price
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 fn margin_init(&self) -> Decimal {
414 self.margin_init
415 }
416
417 fn margin_maint(&self) -> Decimal {
418 self.margin_maint
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use rstest::rstest;
425 use rust_decimal_macros::dec;
426
427 use crate::{
428 enums::{AssetClass, InstrumentClass, OptionKind},
429 identifiers::{InstrumentId, Symbol},
430 instruments::{CryptoOption, Instrument, stubs::*},
431 types::{Currency, Money, Price, Quantity},
432 };
433
434 #[rstest]
435 fn test_trait_accessors(crypto_option_btc_deribit: CryptoOption) {
436 assert_eq!(
437 crypto_option_btc_deribit.id(),
438 InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
439 );
440 assert_eq!(
441 crypto_option_btc_deribit.asset_class(),
442 AssetClass::Cryptocurrency
443 );
444 assert_eq!(
445 crypto_option_btc_deribit.instrument_class(),
446 InstrumentClass::Option
447 );
448 assert_eq!(
449 crypto_option_btc_deribit.option_kind(),
450 Some(OptionKind::Put)
451 );
452 assert_eq!(
453 crypto_option_btc_deribit.strike_price(),
454 Some(Price::from("16000.000"))
455 );
456 assert!(!crypto_option_btc_deribit.is_inverse());
457 assert_eq!(crypto_option_btc_deribit.price_precision(), 3);
458 assert_eq!(crypto_option_btc_deribit.size_precision(), 1);
459 assert_eq!(
460 crypto_option_btc_deribit.min_quantity(),
461 Some(Quantity::from("0.1"))
462 );
463 assert!(crypto_option_btc_deribit.activation_ns().is_some());
464 assert!(crypto_option_btc_deribit.expiration_ns().is_some());
465 }
466
467 #[rstest]
468 #[case::unspecified(None)]
469 #[case::fractional(Some(Quantity::from("0.01")))]
470 #[case::whole(Some(Quantity::from("2")))]
471 fn test_builder_preserves_minimum_quantity(#[case] min_quantity: Option<Quantity>) {
472 let instrument = CryptoOption::builder()
473 .instrument_id(InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"))
474 .raw_symbol(Symbol::from("BTC-13JAN23-16000-P"))
475 .underlying(Currency::BTC())
476 .quote_currency(Currency::USD())
477 .settlement_currency(Currency::BTC())
478 .is_inverse(false)
479 .option_kind(OptionKind::Put)
480 .strike_price(Price::from("16000"))
481 .activation_ns(1.into())
482 .expiration_ns(2.into())
483 .price_precision(1)
484 .size_precision(2)
485 .price_increment(Price::from("0.1"))
486 .size_increment(Quantity::from("0.01"))
487 .maybe_min_quantity(min_quantity)
488 .ts_event(3.into())
489 .ts_init(4.into())
490 .build()
491 .unwrap();
492
493 assert_eq!(instrument.min_quantity(), min_quantity);
494 }
495
496 #[rstest]
497 fn test_new_checked_price_precision_mismatch() {
498 let result = CryptoOption::new_checked(
499 InstrumentId::from("TEST.DERIBIT"),
500 Symbol::from("TEST"),
501 Currency::BTC(),
502 Currency::USD(),
503 Currency::BTC(),
504 false,
505 OptionKind::Call,
506 Price::from("50000.0"),
507 0.into(),
508 0.into(),
509 4, 1,
511 Price::from("0.001"),
512 Quantity::from("0.1"),
513 None,
514 None,
515 None,
516 None,
517 None,
518 None,
519 None,
520 None,
521 None,
522 None,
523 None,
524 None,
525 0.into(),
526 0.into(),
527 );
528 assert!(result.is_err());
529 }
530
531 #[rstest]
532 fn test_new_checked_rejects_non_positive_lot_size() {
533 let result = CryptoOption::new_checked(
534 InstrumentId::from("TEST.DERIBIT"),
535 Symbol::from("TEST"),
536 Currency::BTC(),
537 Currency::USD(),
538 Currency::BTC(),
539 false,
540 OptionKind::Call,
541 Price::from("50000.0"),
542 0.into(),
543 0.into(),
544 1,
545 1,
546 Price::from("0.1"),
547 Quantity::from("0.1"),
548 None,
549 Some(Quantity::from("0")),
550 None,
551 None,
552 None,
553 None,
554 None,
555 None,
556 None,
557 None,
558 None,
559 None,
560 0.into(),
561 0.into(),
562 );
563 assert!(result.is_err());
564 }
565
566 #[rstest]
567 #[case(Price::from("0"))]
568 #[case(Price::from("-1"))]
569 fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
570 let result = CryptoOption::new_checked(
571 InstrumentId::from("TEST.DERIBIT"),
572 Symbol::from("TEST"),
573 Currency::BTC(),
574 Currency::USD(),
575 Currency::BTC(),
576 false,
577 OptionKind::Call,
578 strike_price,
579 0.into(),
580 0.into(),
581 1,
582 1,
583 Price::from("0.1"),
584 Quantity::from("0.1"),
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
601 assert!(
604 result
605 .unwrap_err()
606 .to_string()
607 .contains("'strike_price' not positive")
608 );
609 }
610
611 #[rstest]
612 fn test_serialization_roundtrip(crypto_option_btc_deribit: CryptoOption) {
613 let json = serde_json::to_string(&crypto_option_btc_deribit).unwrap();
614 let deserialized: CryptoOption = serde_json::from_str(&json).unwrap();
615 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
616 }
617
618 #[rstest]
619 fn test_builder_matches_new_checked() {
620 let positional = CryptoOption::new_checked(
621 InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
622 Symbol::from("BTC-13JAN23-16000-P"),
623 Currency::BTC(),
624 Currency::USDC(),
625 Currency::USDT(),
626 false,
627 OptionKind::Put,
628 Price::from("16000.000"),
629 1.into(),
630 2.into(),
631 3,
632 1,
633 Price::from("0.001"),
634 Quantity::from("0.1"),
635 Some(Quantity::from("10")),
636 Some(Quantity::from("5")),
637 Some(Quantity::from("1000.0")),
638 Some(Quantity::from("0.1")),
639 Some(Money::new(1_000_000.0, Currency::USDC())),
640 Some(Money::new(10.0, Currency::USDC())),
641 Some(Price::from("99999.999")),
642 Some(Price::from("0.001")),
643 Some(dec!(0.01)),
644 Some(dec!(0.02)),
645 None,
646 None,
647 1.into(),
648 2.into(),
649 )
650 .unwrap();
651
652 let built = CryptoOption::builder()
653 .instrument_id(InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"))
654 .raw_symbol(Symbol::from("BTC-13JAN23-16000-P"))
655 .underlying(Currency::BTC())
656 .quote_currency(Currency::USDC())
657 .settlement_currency(Currency::USDT())
658 .is_inverse(false)
659 .option_kind(OptionKind::Put)
660 .strike_price(Price::from("16000.000"))
661 .activation_ns(1.into())
662 .expiration_ns(2.into())
663 .price_precision(3)
664 .size_precision(1)
665 .price_increment(Price::from("0.001"))
666 .size_increment(Quantity::from("0.1"))
667 .multiplier(Quantity::from("10"))
668 .lot_size(Quantity::from("5"))
669 .max_quantity(Quantity::from("1000.0"))
670 .min_quantity(Quantity::from("0.1"))
671 .max_notional(Money::new(1_000_000.0, Currency::USDC()))
672 .min_notional(Money::new(10.0, Currency::USDC()))
673 .max_price(Price::from("99999.999"))
674 .min_price(Price::from("0.001"))
675 .margin_init(dec!(0.01))
676 .margin_maint(dec!(0.02))
677 .ts_event(1.into())
678 .ts_init(2.into())
679 .build()
680 .unwrap();
681
682 assert_eq!(
683 serde_json::to_value(&positional).unwrap(),
684 serde_json::to_value(&built).unwrap(),
685 );
686 }
687}