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 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 // Not applicable
342    }
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 // Not applicable (these are tradfi MICs)
362    }
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, // mismatch
496            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 on the parameter name, not merely `is_err`: this constructor validates a
594        // dozen other fields, and a bare error check would pass if an unrelated one fired.
595        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}