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 tick_scheme(&self) -> Option<Ustr> {
298 self.tick_scheme
299 }
300 fn into_any(self) -> InstrumentAny {
301 InstrumentAny::CryptoOption(self)
302 }
303
304 fn id(&self) -> InstrumentId {
305 self.id
306 }
307
308 fn raw_symbol(&self) -> Symbol {
309 self.raw_symbol
310 }
311
312 fn asset_class(&self) -> AssetClass {
313 AssetClass::Cryptocurrency
314 }
315
316 fn instrument_class(&self) -> InstrumentClass {
317 InstrumentClass::Option
318 }
319
320 fn underlying(&self) -> Option<Ustr> {
321 Some(self.underlying.code)
322 }
323
324 fn base_currency(&self) -> Option<Currency> {
325 Some(self.underlying)
326 }
327
328 fn quote_currency(&self) -> Currency {
329 self.quote_currency
330 }
331
332 fn settlement_currency(&self) -> Currency {
333 self.settlement_currency
334 }
335
336 fn is_inverse(&self) -> bool {
337 self.is_inverse
338 }
339
340 fn isin(&self) -> Option<Ustr> {
341 None }
343
344 fn option_kind(&self) -> Option<OptionKind> {
345 Some(self.option_kind)
346 }
347
348 fn strike_price(&self) -> Option<Price> {
349 Some(self.strike_price)
350 }
351
352 fn activation_ns(&self) -> Option<UnixNanos> {
353 Some(self.activation_ns)
354 }
355
356 fn expiration_ns(&self) -> Option<UnixNanos> {
357 Some(self.expiration_ns)
358 }
359
360 fn exchange(&self) -> Option<Ustr> {
361 None }
363
364 fn price_precision(&self) -> u8 {
365 self.price_precision
366 }
367
368 fn size_precision(&self) -> u8 {
369 self.size_precision
370 }
371
372 fn price_increment(&self) -> Price {
373 self.price_increment
374 }
375
376 fn size_increment(&self) -> Quantity {
377 self.size_increment
378 }
379
380 fn multiplier(&self) -> Quantity {
381 self.multiplier
382 }
383
384 fn lot_size(&self) -> Option<Quantity> {
385 Some(self.lot_size)
386 }
387
388 fn max_quantity(&self) -> Option<Quantity> {
389 self.max_quantity
390 }
391
392 fn min_quantity(&self) -> Option<Quantity> {
393 self.min_quantity
394 }
395
396 fn max_notional(&self) -> Option<Money> {
397 self.max_notional
398 }
399
400 fn min_notional(&self) -> Option<Money> {
401 self.min_notional
402 }
403
404 fn max_price(&self) -> Option<Price> {
405 self.max_price
406 }
407
408 fn min_price(&self) -> Option<Price> {
409 self.min_price
410 }
411
412 fn ts_event(&self) -> UnixNanos {
413 self.ts_event
414 }
415
416 fn ts_init(&self) -> UnixNanos {
417 self.ts_init
418 }
419
420 fn margin_init(&self) -> Decimal {
421 self.margin_init
422 }
423
424 fn margin_maint(&self) -> Decimal {
425 self.margin_maint
426 }
427
428 fn maker_fee(&self) -> Decimal {
429 self.maker_fee
430 }
431
432 fn taker_fee(&self) -> Decimal {
433 self.taker_fee
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use rstest::rstest;
440 use rust_decimal_macros::dec;
441
442 use crate::{
443 enums::{AssetClass, InstrumentClass, OptionKind},
444 identifiers::{InstrumentId, Symbol},
445 instruments::{CryptoOption, Instrument, stubs::*},
446 types::{Currency, Money, Price, Quantity},
447 };
448
449 #[rstest]
450 fn test_trait_accessors(crypto_option_btc_deribit: CryptoOption) {
451 assert_eq!(
452 crypto_option_btc_deribit.id(),
453 InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
454 );
455 assert_eq!(
456 crypto_option_btc_deribit.asset_class(),
457 AssetClass::Cryptocurrency
458 );
459 assert_eq!(
460 crypto_option_btc_deribit.instrument_class(),
461 InstrumentClass::Option
462 );
463 assert_eq!(
464 crypto_option_btc_deribit.option_kind(),
465 Some(OptionKind::Put)
466 );
467 assert_eq!(
468 crypto_option_btc_deribit.strike_price(),
469 Some(Price::from("16000.000"))
470 );
471 assert!(!crypto_option_btc_deribit.is_inverse());
472 assert_eq!(crypto_option_btc_deribit.price_precision(), 3);
473 assert_eq!(crypto_option_btc_deribit.size_precision(), 1);
474 assert_eq!(
475 crypto_option_btc_deribit.min_quantity(),
476 Some(Quantity::from("0.1"))
477 );
478 assert!(crypto_option_btc_deribit.activation_ns().is_some());
479 assert!(crypto_option_btc_deribit.expiration_ns().is_some());
480 }
481
482 #[rstest]
483 fn test_new_checked_price_precision_mismatch() {
484 let result = CryptoOption::new_checked(
485 InstrumentId::from("TEST.DERIBIT"),
486 Symbol::from("TEST"),
487 Currency::BTC(),
488 Currency::USD(),
489 Currency::BTC(),
490 false,
491 OptionKind::Call,
492 Price::from("50000.0"),
493 0.into(),
494 0.into(),
495 4, 1,
497 Price::from("0.001"),
498 Quantity::from("0.1"),
499 None,
500 None,
501 None,
502 None,
503 None,
504 None,
505 None,
506 None,
507 None,
508 None,
509 None,
510 None,
511 None,
512 None,
513 0.into(),
514 0.into(),
515 );
516 assert!(result.is_err());
517 }
518
519 #[rstest]
520 fn test_new_checked_rejects_non_positive_lot_size() {
521 let result = CryptoOption::new_checked(
522 InstrumentId::from("TEST.DERIBIT"),
523 Symbol::from("TEST"),
524 Currency::BTC(),
525 Currency::USD(),
526 Currency::BTC(),
527 false,
528 OptionKind::Call,
529 Price::from("50000.0"),
530 0.into(),
531 0.into(),
532 1,
533 1,
534 Price::from("0.1"),
535 Quantity::from("0.1"),
536 None,
537 Some(Quantity::from("0")),
538 None,
539 None,
540 None,
541 None,
542 None,
543 None,
544 None,
545 None,
546 None,
547 None,
548 None,
549 None,
550 0.into(),
551 0.into(),
552 );
553 assert!(result.is_err());
554 }
555
556 #[rstest]
557 #[case(Price::from("0"))]
558 #[case(Price::from("-1"))]
559 fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
560 let result = CryptoOption::new_checked(
561 InstrumentId::from("TEST.DERIBIT"),
562 Symbol::from("TEST"),
563 Currency::BTC(),
564 Currency::USD(),
565 Currency::BTC(),
566 false,
567 OptionKind::Call,
568 strike_price,
569 0.into(),
570 0.into(),
571 1,
572 1,
573 Price::from("0.1"),
574 Quantity::from("0.1"),
575 None,
576 None,
577 None,
578 None,
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 None,
589 0.into(),
590 0.into(),
591 );
592
593 assert!(
596 result
597 .unwrap_err()
598 .to_string()
599 .contains("'strike_price' not positive")
600 );
601 }
602
603 #[rstest]
604 fn test_serialization_roundtrip(crypto_option_btc_deribit: CryptoOption) {
605 let json = serde_json::to_string(&crypto_option_btc_deribit).unwrap();
606 let deserialized: CryptoOption = serde_json::from_str(&json).unwrap();
607 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
608 }
609
610 #[rstest]
611 fn test_builder_matches_new_checked() {
612 let positional = CryptoOption::new_checked(
613 InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
614 Symbol::from("BTC-13JAN23-16000-P"),
615 Currency::BTC(),
616 Currency::USDC(),
617 Currency::USDT(),
618 false,
619 OptionKind::Put,
620 Price::from("16000.000"),
621 1.into(),
622 2.into(),
623 3,
624 1,
625 Price::from("0.001"),
626 Quantity::from("0.1"),
627 Some(Quantity::from("10")),
628 Some(Quantity::from("5")),
629 Some(Quantity::from("1000.0")),
630 Some(Quantity::from("0.1")),
631 Some(Money::new(1_000_000.0, Currency::USDC())),
632 Some(Money::new(10.0, Currency::USDC())),
633 Some(Price::from("99999.999")),
634 Some(Price::from("0.001")),
635 Some(dec!(0.01)),
636 Some(dec!(0.02)),
637 Some(dec!(0.0002)),
638 Some(dec!(0.0004)),
639 None,
640 None,
641 1.into(),
642 2.into(),
643 )
644 .unwrap();
645
646 let built = CryptoOption::builder()
647 .instrument_id(InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"))
648 .raw_symbol(Symbol::from("BTC-13JAN23-16000-P"))
649 .underlying(Currency::BTC())
650 .quote_currency(Currency::USDC())
651 .settlement_currency(Currency::USDT())
652 .is_inverse(false)
653 .option_kind(OptionKind::Put)
654 .strike_price(Price::from("16000.000"))
655 .activation_ns(1.into())
656 .expiration_ns(2.into())
657 .price_precision(3)
658 .size_precision(1)
659 .price_increment(Price::from("0.001"))
660 .size_increment(Quantity::from("0.1"))
661 .multiplier(Quantity::from("10"))
662 .lot_size(Quantity::from("5"))
663 .max_quantity(Quantity::from("1000.0"))
664 .min_quantity(Quantity::from("0.1"))
665 .max_notional(Money::new(1_000_000.0, Currency::USDC()))
666 .min_notional(Money::new(10.0, Currency::USDC()))
667 .max_price(Price::from("99999.999"))
668 .min_price(Price::from("0.001"))
669 .margin_init(dec!(0.01))
670 .margin_maint(dec!(0.02))
671 .maker_fee(dec!(0.0002))
672 .taker_fee(dec!(0.0004))
673 .ts_event(1.into())
674 .ts_init(2.into())
675 .build()
676 .unwrap();
677
678 assert_eq!(
679 serde_json::to_value(&positional).unwrap(),
680 serde_json::to_value(&built).unwrap(),
681 );
682 }
683}