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, 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.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    #[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    /// Returns a fluent builder for a [`CryptoOption`] instance.
207    ///
208    /// Required fields are enforced at compile time; optional fields can be omitted and use the
209    /// same defaults as checked construction. The same correctness checks run on `build`.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if any input validation fails.
214    #[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 // Not applicable
339    }
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 // Not applicable (these are tradfi MICs)
359    }
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, // mismatch
501            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 on the parameter name, not merely `is_err`: this constructor validates a
599        // dozen other fields, and a bare error check would pass if an unrelated one fired.
600        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}