Skip to main content

nautilus_model/instruments/
crypto_option.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19    Params, UnixNanos,
20    correctness::{CorrectnessResult, CorrectnessResultExt, FAILED, 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/// Represents a generic option contract instrument.
39#[repr(C)]
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.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    /// The instrument ID.
51    pub id: InstrumentId,
52    /// The raw/local/native symbol for the instrument, assigned by the venue.
53    pub raw_symbol: Symbol,
54    /// The underlying asset.
55    pub underlying: Currency,
56    /// The contract quote currency.
57    pub quote_currency: Currency,
58    /// The settlement currency.
59    pub settlement_currency: Currency,
60    /// If the instrument costing is inverse (quantity expressed in quote currency units).
61    pub is_inverse: bool,
62    /// The kind of option (PUT | CALL).
63    pub option_kind: OptionKind,
64    /// The option strike price.
65    pub strike_price: Price,
66    /// UNIX timestamp (nanoseconds) for contract activation.
67    pub activation_ns: UnixNanos,
68    /// UNIX timestamp (nanoseconds) for contract expiration.
69    pub expiration_ns: UnixNanos,
70    /// The option contract currency.
71    pub price_precision: u8,
72    /// The trading size decimal precision.
73    pub size_precision: u8,
74    /// The minimum price increment (tick size).
75    pub price_increment: Price,
76    /// The minimum size increment.
77    pub size_increment: Quantity,
78    /// The option multiplier.
79    pub multiplier: Quantity,
80    /// The rounded lot unit size (standard/board).
81    pub lot_size: Quantity,
82    /// The initial (order) margin requirement in percentage of order value.
83    pub margin_init: Decimal,
84    /// The maintenance (position) margin in percentage of position value.
85    pub margin_maint: Decimal,
86    /// The fee rate for liquidity makers as a percentage of order value.
87    pub maker_fee: Decimal,
88    /// The fee rate for liquidity takers as a percentage of order value.
89    pub taker_fee: Decimal,
90    /// The maximum allowable order quantity.
91    pub max_quantity: Option<Quantity>,
92    /// The minimum allowable order quantity.
93    pub min_quantity: Option<Quantity>,
94    /// The maximum allowable order notional value.
95    pub max_notional: Option<Money>,
96    /// The minimum allowable order notional value.
97    pub min_notional: Option<Money>,
98    /// The maximum allowable quoted price.
99    pub max_price: Option<Price>,
100    /// The minimum allowable quoted price.
101    pub min_price: Option<Price>,
102    /// The registered variable tick scheme name.
103    pub tick_scheme: Option<Ustr>,
104    /// Additional instrument metadata as a JSON-serializable dictionary.
105    pub info: Option<Params>,
106    /// UNIX timestamp (nanoseconds) when the data event occurred.
107    pub ts_event: UnixNanos,
108    /// UNIX timestamp (nanoseconds) when the data object was initialized.
109    pub ts_init: UnixNanos,
110}
111
112#[bon::bon]
113impl CryptoOption {
114    /// Creates a new [`CryptoOption`] instance with correctness checking.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if any input validation fails.
119    ///
120    /// # Notes
121    ///
122    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
123    #[expect(clippy::too_many_arguments)]
124    pub fn new_checked(
125        instrument_id: InstrumentId,
126        raw_symbol: Symbol,
127        underlying: Currency,
128        quote_currency: Currency,
129        settlement_currency: Currency,
130        is_inverse: bool,
131        option_kind: OptionKind,
132        strike_price: Price,
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_equal_u8(
157            price_precision,
158            price_increment.precision,
159            stringify!(price_precision),
160            stringify!(price_increment.precision),
161        )?;
162        check_equal_u8(
163            size_precision,
164            size_increment.precision,
165            stringify!(size_precision),
166            stringify!(size_increment.precision),
167        )?;
168        check_positive_price(price_increment, stringify!(price_increment))?;
169        check_positive_quantity(size_increment, stringify!(size_increment))?;
170        check_tick_scheme(tick_scheme)?;
171
172        if let Some(multiplier) = multiplier {
173            check_positive_quantity(multiplier, stringify!(multiplier))?;
174        }
175
176        if let Some(lot_size) = lot_size {
177            check_positive_quantity(lot_size, stringify!(lot_size))?;
178        }
179
180        Ok(Self {
181            id: instrument_id,
182            raw_symbol,
183            underlying,
184            quote_currency,
185            settlement_currency,
186            is_inverse,
187            option_kind,
188            strike_price,
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: Some(min_quantity.unwrap_or(1.into())),
205            max_price,
206            min_price,
207            tick_scheme,
208            info,
209            ts_event,
210            ts_init,
211        })
212    }
213
214    /// Creates a new [`CryptoOption`] instance.
215    ///
216    /// # Panics
217    ///
218    /// Panics if any parameter is invalid (see `new_checked`).
219    #[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        option_kind: OptionKind,
229        strike_price: Price,
230        activation_ns: UnixNanos,
231        expiration_ns: UnixNanos,
232        price_precision: u8,
233        size_precision: u8,
234        price_increment: Price,
235        size_increment: Quantity,
236        multiplier: Option<Quantity>,
237        lot_size: Option<Quantity>,
238        max_quantity: Option<Quantity>,
239        min_quantity: Option<Quantity>,
240        max_notional: Option<Money>,
241        min_notional: Option<Money>,
242        max_price: Option<Price>,
243        min_price: Option<Price>,
244        margin_init: Option<Decimal>,
245        margin_maint: Option<Decimal>,
246        maker_fee: Option<Decimal>,
247        taker_fee: Option<Decimal>,
248        tick_scheme: Option<Ustr>,
249        info: Option<Params>,
250        ts_event: UnixNanos,
251        ts_init: UnixNanos,
252    ) -> Self {
253        Self::new_checked(
254            instrument_id,
255            raw_symbol,
256            underlying,
257            quote_currency,
258            settlement_currency,
259            is_inverse,
260            option_kind,
261            strike_price,
262            activation_ns,
263            expiration_ns,
264            price_precision,
265            size_precision,
266            price_increment,
267            size_increment,
268            multiplier,
269            lot_size,
270            max_quantity,
271            min_quantity,
272            max_notional,
273            min_notional,
274            max_price,
275            min_price,
276            margin_init,
277            margin_maint,
278            maker_fee,
279            taker_fee,
280            tick_scheme,
281            info,
282            ts_event,
283            ts_init,
284        )
285        .expect_display(FAILED)
286    }
287
288    /// Returns a fluent builder for a [`CryptoOption`] instance.
289    ///
290    /// Required fields are enforced at compile time; optional fields can be omitted and default
291    /// the same way they do in [`CryptoOption::new_checked`], which the builder calls so the same
292    /// correctness checks run on `build`.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if any input validation fails (see [`CryptoOption::new_checked`]).
297    #[builder(start_fn = builder, finish_fn = build)]
298    pub fn build_checked(
299        instrument_id: InstrumentId,
300        raw_symbol: Symbol,
301        underlying: Currency,
302        quote_currency: Currency,
303        settlement_currency: Currency,
304        is_inverse: bool,
305        option_kind: OptionKind,
306        strike_price: Price,
307        activation_ns: UnixNanos,
308        expiration_ns: UnixNanos,
309        price_precision: u8,
310        size_precision: u8,
311        price_increment: Price,
312        size_increment: Quantity,
313        multiplier: Option<Quantity>,
314        lot_size: Option<Quantity>,
315        max_quantity: Option<Quantity>,
316        min_quantity: Option<Quantity>,
317        max_notional: Option<Money>,
318        min_notional: Option<Money>,
319        max_price: Option<Price>,
320        min_price: Option<Price>,
321        margin_init: Option<Decimal>,
322        margin_maint: Option<Decimal>,
323        maker_fee: Option<Decimal>,
324        taker_fee: Option<Decimal>,
325        tick_scheme: Option<Ustr>,
326        info: Option<Params>,
327        ts_event: UnixNanos,
328        ts_init: UnixNanos,
329    ) -> CorrectnessResult<Self> {
330        Self::new_checked(
331            instrument_id,
332            raw_symbol,
333            underlying,
334            quote_currency,
335            settlement_currency,
336            is_inverse,
337            option_kind,
338            strike_price,
339            activation_ns,
340            expiration_ns,
341            price_precision,
342            size_precision,
343            price_increment,
344            size_increment,
345            multiplier,
346            lot_size,
347            max_quantity,
348            min_quantity,
349            max_notional,
350            min_notional,
351            max_price,
352            min_price,
353            margin_init,
354            margin_maint,
355            maker_fee,
356            taker_fee,
357            tick_scheme,
358            info,
359            ts_event,
360            ts_init,
361        )
362    }
363}
364
365impl PartialEq<Self> for CryptoOption {
366    fn eq(&self, other: &Self) -> bool {
367        self.id == other.id
368    }
369}
370
371impl Eq for CryptoOption {}
372
373impl Hash for CryptoOption {
374    fn hash<H: Hasher>(&self, state: &mut H) {
375        self.id.hash(state);
376    }
377}
378
379impl Instrument for CryptoOption {
380    fn tick_scheme(&self) -> Option<Ustr> {
381        self.tick_scheme
382    }
383    fn into_any(self) -> InstrumentAny {
384        InstrumentAny::CryptoOption(self)
385    }
386
387    fn id(&self) -> InstrumentId {
388        self.id
389    }
390
391    fn raw_symbol(&self) -> Symbol {
392        self.raw_symbol
393    }
394
395    fn asset_class(&self) -> AssetClass {
396        AssetClass::Cryptocurrency
397    }
398
399    fn instrument_class(&self) -> InstrumentClass {
400        InstrumentClass::Option
401    }
402
403    fn underlying(&self) -> Option<Ustr> {
404        Some(self.underlying.code)
405    }
406
407    fn base_currency(&self) -> Option<Currency> {
408        Some(self.underlying)
409    }
410
411    fn quote_currency(&self) -> Currency {
412        self.quote_currency
413    }
414
415    fn settlement_currency(&self) -> Currency {
416        self.settlement_currency
417    }
418
419    fn is_inverse(&self) -> bool {
420        self.is_inverse
421    }
422
423    fn isin(&self) -> Option<Ustr> {
424        None // Not applicable
425    }
426
427    fn option_kind(&self) -> Option<OptionKind> {
428        Some(self.option_kind)
429    }
430
431    fn strike_price(&self) -> Option<Price> {
432        Some(self.strike_price)
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 // Not applicable (these are tradfi MICs)
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 rstest::rstest;
523    use rust_decimal_macros::dec;
524
525    use crate::{
526        enums::{AssetClass, InstrumentClass, OptionKind},
527        identifiers::{InstrumentId, Symbol},
528        instruments::{CryptoOption, Instrument, stubs::*},
529        types::{Currency, Money, Price, Quantity},
530    };
531
532    #[rstest]
533    fn test_trait_accessors(crypto_option_btc_deribit: CryptoOption) {
534        assert_eq!(
535            crypto_option_btc_deribit.id(),
536            InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
537        );
538        assert_eq!(
539            crypto_option_btc_deribit.asset_class(),
540            AssetClass::Cryptocurrency
541        );
542        assert_eq!(
543            crypto_option_btc_deribit.instrument_class(),
544            InstrumentClass::Option
545        );
546        assert_eq!(
547            crypto_option_btc_deribit.option_kind(),
548            Some(OptionKind::Put)
549        );
550        assert_eq!(
551            crypto_option_btc_deribit.strike_price(),
552            Some(Price::from("16000.000"))
553        );
554        assert!(!crypto_option_btc_deribit.is_inverse());
555        assert_eq!(crypto_option_btc_deribit.price_precision(), 3);
556        assert_eq!(crypto_option_btc_deribit.size_precision(), 1);
557        assert_eq!(
558            crypto_option_btc_deribit.min_quantity(),
559            Some(Quantity::from("0.1"))
560        );
561        assert!(crypto_option_btc_deribit.activation_ns().is_some());
562        assert!(crypto_option_btc_deribit.expiration_ns().is_some());
563    }
564
565    #[rstest]
566    fn test_new_checked_price_precision_mismatch() {
567        let result = CryptoOption::new_checked(
568            InstrumentId::from("TEST.DERIBIT"),
569            Symbol::from("TEST"),
570            Currency::BTC(),
571            Currency::USD(),
572            Currency::BTC(),
573            false,
574            OptionKind::Call,
575            Price::from("50000.0"),
576            0.into(),
577            0.into(),
578            4, // mismatch
579            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    fn test_new_checked_rejects_non_positive_lot_size() {
604        let result = CryptoOption::new_checked(
605            InstrumentId::from("TEST.DERIBIT"),
606            Symbol::from("TEST"),
607            Currency::BTC(),
608            Currency::USD(),
609            Currency::BTC(),
610            false,
611            OptionKind::Call,
612            Price::from("50000.0"),
613            0.into(),
614            0.into(),
615            1,
616            1,
617            Price::from("0.1"),
618            Quantity::from("0.1"),
619            None,
620            Some(Quantity::from("0")),
621            None,
622            None,
623            None,
624            None,
625            None,
626            None,
627            None,
628            None,
629            None,
630            None,
631            None,
632            None,
633            0.into(),
634            0.into(),
635        );
636        assert!(result.is_err());
637    }
638
639    #[rstest]
640    fn test_serialization_roundtrip(crypto_option_btc_deribit: CryptoOption) {
641        let json = serde_json::to_string(&crypto_option_btc_deribit).unwrap();
642        let deserialized: CryptoOption = serde_json::from_str(&json).unwrap();
643        assert_eq!(crypto_option_btc_deribit, deserialized);
644    }
645
646    #[rstest]
647    fn test_builder_matches_new_checked() {
648        let positional = CryptoOption::new_checked(
649            InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"),
650            Symbol::from("BTC-13JAN23-16000-P"),
651            Currency::BTC(),
652            Currency::USDC(),
653            Currency::USDT(),
654            false,
655            OptionKind::Put,
656            Price::from("16000.000"),
657            1.into(),
658            2.into(),
659            3,
660            1,
661            Price::from("0.001"),
662            Quantity::from("0.1"),
663            Some(Quantity::from("10")),
664            Some(Quantity::from("5")),
665            Some(Quantity::from("1000.0")),
666            Some(Quantity::from("0.1")),
667            Some(Money::new(1_000_000.0, Currency::USDC())),
668            Some(Money::new(10.0, Currency::USDC())),
669            Some(Price::from("99999.999")),
670            Some(Price::from("0.001")),
671            Some(dec!(0.01)),
672            Some(dec!(0.02)),
673            Some(dec!(0.0002)),
674            Some(dec!(0.0004)),
675            None,
676            None,
677            1.into(),
678            2.into(),
679        )
680        .unwrap();
681
682        let built = CryptoOption::builder()
683            .instrument_id(InstrumentId::from("BTC-13JAN23-16000-P.DERIBIT"))
684            .raw_symbol(Symbol::from("BTC-13JAN23-16000-P"))
685            .underlying(Currency::BTC())
686            .quote_currency(Currency::USDC())
687            .settlement_currency(Currency::USDT())
688            .is_inverse(false)
689            .option_kind(OptionKind::Put)
690            .strike_price(Price::from("16000.000"))
691            .activation_ns(1.into())
692            .expiration_ns(2.into())
693            .price_precision(3)
694            .size_precision(1)
695            .price_increment(Price::from("0.001"))
696            .size_increment(Quantity::from("0.1"))
697            .multiplier(Quantity::from("10"))
698            .lot_size(Quantity::from("5"))
699            .max_quantity(Quantity::from("1000.0"))
700            .min_quantity(Quantity::from("0.1"))
701            .max_notional(Money::new(1_000_000.0, Currency::USDC()))
702            .min_notional(Money::new(10.0, Currency::USDC()))
703            .max_price(Price::from("99999.999"))
704            .min_price(Price::from("0.001"))
705            .margin_init(dec!(0.01))
706            .margin_maint(dec!(0.02))
707            .maker_fee(dec!(0.0002))
708            .taker_fee(dec!(0.0004))
709            .ts_event(1.into())
710            .ts_init(2.into())
711            .build()
712            .unwrap();
713
714        assert_eq!(
715            serde_json::to_value(&positional).unwrap(),
716            serde_json::to_value(&built).unwrap(),
717        );
718    }
719}