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 maximum allowable order quantity.
87    pub max_quantity: Option<Quantity>,
88    /// The minimum allowable order quantity.
89    pub min_quantity: Option<Quantity>,
90    /// The maximum allowable order notional value.
91    pub max_notional: Option<Money>,
92    /// The minimum allowable order notional value.
93    pub min_notional: Option<Money>,
94    /// The maximum allowable quoted price.
95    pub max_price: Option<Price>,
96    /// The minimum allowable quoted price.
97    pub min_price: Option<Price>,
98    /// The registered variable tick scheme name.
99    pub tick_scheme: Option<Ustr>,
100    /// Additional instrument metadata as a JSON-serializable dictionary.
101    pub info: Option<Params>,
102    /// UNIX timestamp (nanoseconds) when the data event occurred.
103    pub ts_event: UnixNanos,
104    /// UNIX timestamp (nanoseconds) when the data object was initialized.
105    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    /// Returns a fluent builder for a [`CryptoOption`] instance.
199    ///
200    /// Required fields are enforced at compile time; optional fields can be omitted and use the
201    /// same defaults as checked construction. The same correctness checks run on `build`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if any input validation fails.
206    #[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 // Not applicable
327    }
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 // Not applicable (these are tradfi MICs)
347    }
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, // mismatch
510            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 on the parameter name, not merely `is_err`: this constructor validates a
602        // dozen other fields, and a bare error check would pass if an unrelated one fired.
603        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}