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