Skip to main content

nautilus_model/instruments/
option_contract.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::{
21        CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8, check_valid_string_ascii,
22        check_valid_string_ascii_optional,
23    },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
30use crate::{
31    enums::{AssetClass, InstrumentClass, OptionKind},
32    identifiers::{InstrumentId, Symbol},
33    types::{
34        currency::Currency,
35        money::Money,
36        price::{Price, check_positive_price},
37        quantity::{Quantity, check_positive_quantity},
38    },
39};
40
41/// Represents a generic option contract instrument.
42#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45    feature = "python",
46    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
47)]
48#[cfg_attr(
49    feature = "python",
50    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
51)]
52pub struct OptionContract {
53    /// The instrument ID.
54    pub id: InstrumentId,
55    /// The raw/local/native symbol for the instrument, assigned by the venue.
56    pub raw_symbol: Symbol,
57    /// The option contract asset class.
58    pub asset_class: AssetClass,
59    /// The exchange ISO 10383 Market Identifier Code (MIC) where the instrument trades.
60    pub exchange: Option<Ustr>,
61    /// The underlying asset.
62    pub underlying: Ustr,
63    /// The kind of option (PUT | CALL).
64    pub option_kind: OptionKind,
65    /// The option strike price.
66    pub strike_price: Price,
67    /// UNIX timestamp (nanoseconds) for contract activation.
68    pub activation_ns: UnixNanos,
69    /// UNIX timestamp (nanoseconds) for contract expiration.
70    pub expiration_ns: UnixNanos,
71    /// The option contract currency.
72    pub currency: Currency,
73    /// The price decimal precision.
74    pub price_precision: u8,
75    /// The minimum price increment (tick size).
76    pub price_increment: Price,
77    /// The minimum size increment.
78    pub size_increment: Quantity,
79    /// The trading size decimal precision.
80    pub size_precision: u8,
81    /// The option multiplier.
82    pub multiplier: Quantity,
83    /// The rounded lot unit size (standard/board).
84    pub lot_size: Quantity,
85    /// The initial (order) margin requirement in percentage of order value.
86    pub margin_init: Decimal,
87    /// The maintenance (position) margin in percentage of position value.
88    pub margin_maint: Decimal,
89    /// The fee rate for liquidity makers as a percentage of order value.
90    pub maker_fee: Decimal,
91    /// The fee rate for liquidity takers as a percentage of order value.
92    pub taker_fee: Decimal,
93    /// The maximum allowable order quantity.
94    pub max_quantity: Option<Quantity>,
95    /// The minimum allowable order quantity.
96    pub min_quantity: Option<Quantity>,
97    /// The maximum allowable quoted price.
98    pub max_price: Option<Price>,
99    /// The minimum allowable quoted price.
100    pub min_price: Option<Price>,
101    /// The registered variable tick scheme name.
102    pub tick_scheme: Option<Ustr>,
103    /// Additional instrument metadata as a JSON-serializable dictionary.
104    pub info: Option<Params>,
105    /// UNIX timestamp (nanoseconds) when the data event occurred.
106    pub ts_event: UnixNanos,
107    /// UNIX timestamp (nanoseconds) when the data object was initialized.
108    pub ts_init: UnixNanos,
109}
110
111#[bon::bon]
112impl OptionContract {
113    /// Creates a new [`OptionContract`] instance with correctness checking.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if any input validation fails.
118    ///
119    /// # Notes
120    ///
121    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
122    #[expect(clippy::too_many_arguments)]
123    pub fn new_checked(
124        instrument_id: InstrumentId,
125        raw_symbol: Symbol,
126        asset_class: AssetClass,
127        exchange: Option<Ustr>,
128        underlying: Ustr,
129        option_kind: OptionKind,
130        strike_price: Price,
131        currency: Currency,
132        activation_ns: UnixNanos,
133        expiration_ns: UnixNanos,
134        price_precision: u8,
135        price_increment: Price,
136        multiplier: Quantity,
137        lot_size: Quantity,
138        max_quantity: Option<Quantity>,
139        min_quantity: Option<Quantity>,
140        max_price: Option<Price>,
141        min_price: Option<Price>,
142        margin_init: Option<Decimal>,
143        margin_maint: Option<Decimal>,
144        maker_fee: Option<Decimal>,
145        taker_fee: Option<Decimal>,
146        tick_scheme: Option<Ustr>,
147        info: Option<Params>,
148        ts_event: UnixNanos,
149        ts_init: UnixNanos,
150    ) -> CorrectnessResult<Self> {
151        check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
152        check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
153        check_equal_u8(
154            price_precision,
155            price_increment.precision,
156            stringify!(price_precision),
157            stringify!(price_increment.precision),
158        )?;
159        check_positive_price(price_increment, stringify!(price_increment))?;
160        check_tick_scheme(tick_scheme)?;
161        check_positive_quantity(multiplier, stringify!(multiplier))?;
162        check_positive_quantity(lot_size, stringify!(lot_size))?;
163
164        Ok(Self {
165            id: instrument_id,
166            raw_symbol,
167            asset_class,
168            exchange,
169            underlying,
170            option_kind,
171            activation_ns,
172            expiration_ns,
173            strike_price,
174            currency,
175            price_precision,
176            price_increment,
177            size_precision: 0,
178            size_increment: Quantity::from(1),
179            multiplier,
180            lot_size,
181            margin_init: margin_init.unwrap_or_default(),
182            margin_maint: margin_maint.unwrap_or_default(),
183            maker_fee: maker_fee.unwrap_or_default(),
184            taker_fee: taker_fee.unwrap_or_default(),
185            tick_scheme,
186            info,
187            max_quantity,
188            min_quantity: Some(min_quantity.unwrap_or(1.into())),
189            max_price,
190            min_price,
191            ts_event,
192            ts_init,
193        })
194    }
195
196    /// Creates a new [`OptionContract`] instance.
197    ///
198    /// # Panics
199    ///
200    /// Panics if any input parameter is invalid (see `new_checked`).
201    #[expect(clippy::too_many_arguments)]
202    #[must_use]
203    pub fn new(
204        instrument_id: InstrumentId,
205        raw_symbol: Symbol,
206        asset_class: AssetClass,
207        exchange: Option<Ustr>,
208        underlying: Ustr,
209        option_kind: OptionKind,
210        strike_price: Price,
211        currency: Currency,
212        activation_ns: UnixNanos,
213        expiration_ns: UnixNanos,
214        price_precision: u8,
215        price_increment: Price,
216        multiplier: Quantity,
217        lot_size: Quantity,
218        max_quantity: Option<Quantity>,
219        min_quantity: Option<Quantity>,
220        max_price: Option<Price>,
221        min_price: Option<Price>,
222        margin_init: Option<Decimal>,
223        margin_maint: Option<Decimal>,
224        maker_fee: Option<Decimal>,
225        taker_fee: Option<Decimal>,
226        tick_scheme: Option<Ustr>,
227        info: Option<Params>,
228        ts_event: UnixNanos,
229        ts_init: UnixNanos,
230    ) -> Self {
231        Self::new_checked(
232            instrument_id,
233            raw_symbol,
234            asset_class,
235            exchange,
236            underlying,
237            option_kind,
238            strike_price,
239            currency,
240            activation_ns,
241            expiration_ns,
242            price_precision,
243            price_increment,
244            multiplier,
245            lot_size,
246            max_quantity,
247            min_quantity,
248            max_price,
249            min_price,
250            margin_init,
251            margin_maint,
252            maker_fee,
253            taker_fee,
254            tick_scheme,
255            info,
256            ts_event,
257            ts_init,
258        )
259        .expect_display(FAILED)
260    }
261
262    /// Returns a fluent builder for a [`OptionContract`] instance.
263    ///
264    /// Required fields are enforced at compile time; optional fields can be omitted and default
265    /// the same way they do in [`OptionContract::new_checked`], which the builder calls so the same
266    /// correctness checks run on `build`.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if any input validation fails (see [`OptionContract::new_checked`]).
271    #[builder(start_fn = builder, finish_fn = build)]
272    pub fn build_checked(
273        instrument_id: InstrumentId,
274        raw_symbol: Symbol,
275        asset_class: AssetClass,
276        exchange: Option<Ustr>,
277        underlying: Ustr,
278        option_kind: OptionKind,
279        strike_price: Price,
280        currency: Currency,
281        activation_ns: UnixNanos,
282        expiration_ns: UnixNanos,
283        price_precision: u8,
284        price_increment: Price,
285        multiplier: Quantity,
286        lot_size: Quantity,
287        max_quantity: Option<Quantity>,
288        min_quantity: Option<Quantity>,
289        max_price: Option<Price>,
290        min_price: Option<Price>,
291        margin_init: Option<Decimal>,
292        margin_maint: Option<Decimal>,
293        maker_fee: Option<Decimal>,
294        taker_fee: Option<Decimal>,
295        tick_scheme: Option<Ustr>,
296        info: Option<Params>,
297        ts_event: UnixNanos,
298        ts_init: UnixNanos,
299    ) -> CorrectnessResult<Self> {
300        Self::new_checked(
301            instrument_id,
302            raw_symbol,
303            asset_class,
304            exchange,
305            underlying,
306            option_kind,
307            strike_price,
308            currency,
309            activation_ns,
310            expiration_ns,
311            price_precision,
312            price_increment,
313            multiplier,
314            lot_size,
315            max_quantity,
316            min_quantity,
317            max_price,
318            min_price,
319            margin_init,
320            margin_maint,
321            maker_fee,
322            taker_fee,
323            tick_scheme,
324            info,
325            ts_event,
326            ts_init,
327        )
328    }
329}
330
331impl PartialEq<Self> for OptionContract {
332    fn eq(&self, other: &Self) -> bool {
333        self.id == other.id
334    }
335}
336
337impl Eq for OptionContract {}
338
339impl Hash for OptionContract {
340    fn hash<H: Hasher>(&self, state: &mut H) {
341        self.id.hash(state);
342    }
343}
344
345impl Instrument for OptionContract {
346    fn tick_scheme(&self) -> Option<Ustr> {
347        self.tick_scheme
348    }
349    fn into_any(self) -> InstrumentAny {
350        InstrumentAny::OptionContract(self)
351    }
352
353    fn id(&self) -> InstrumentId {
354        self.id
355    }
356
357    fn raw_symbol(&self) -> Symbol {
358        self.raw_symbol
359    }
360
361    fn asset_class(&self) -> AssetClass {
362        self.asset_class
363    }
364
365    fn instrument_class(&self) -> InstrumentClass {
366        InstrumentClass::Option
367    }
368    fn underlying(&self) -> Option<Ustr> {
369        Some(self.underlying)
370    }
371
372    fn base_currency(&self) -> Option<Currency> {
373        None
374    }
375
376    fn quote_currency(&self) -> Currency {
377        self.currency
378    }
379
380    fn settlement_currency(&self) -> Currency {
381        self.currency
382    }
383
384    fn isin(&self) -> Option<Ustr> {
385        None
386    }
387
388    fn option_kind(&self) -> Option<OptionKind> {
389        Some(self.option_kind)
390    }
391
392    fn exchange(&self) -> Option<Ustr> {
393        self.exchange
394    }
395
396    fn strike_price(&self) -> Option<Price> {
397        Some(self.strike_price)
398    }
399
400    fn activation_ns(&self) -> Option<UnixNanos> {
401        Some(self.activation_ns)
402    }
403
404    fn expiration_ns(&self) -> Option<UnixNanos> {
405        Some(self.expiration_ns)
406    }
407
408    fn is_inverse(&self) -> bool {
409        false
410    }
411
412    fn price_precision(&self) -> u8 {
413        self.price_precision
414    }
415
416    fn size_precision(&self) -> u8 {
417        0
418    }
419
420    fn price_increment(&self) -> Price {
421        self.price_increment
422    }
423
424    fn size_increment(&self) -> Quantity {
425        Quantity::from(1)
426    }
427
428    fn multiplier(&self) -> Quantity {
429        self.multiplier
430    }
431
432    fn lot_size(&self) -> Option<Quantity> {
433        Some(self.lot_size)
434    }
435
436    fn max_quantity(&self) -> Option<Quantity> {
437        self.max_quantity
438    }
439
440    fn min_quantity(&self) -> Option<Quantity> {
441        self.min_quantity
442    }
443
444    fn max_notional(&self) -> Option<Money> {
445        None
446    }
447
448    fn min_notional(&self) -> Option<Money> {
449        None
450    }
451
452    fn max_price(&self) -> Option<Price> {
453        self.max_price
454    }
455
456    fn min_price(&self) -> Option<Price> {
457        self.min_price
458    }
459
460    fn ts_event(&self) -> UnixNanos {
461        self.ts_event
462    }
463
464    fn ts_init(&self) -> UnixNanos {
465        self.ts_init
466    }
467
468    fn margin_init(&self) -> Decimal {
469        self.margin_init
470    }
471
472    fn margin_maint(&self) -> Decimal {
473        self.margin_maint
474    }
475
476    fn maker_fee(&self) -> Decimal {
477        self.maker_fee
478    }
479
480    fn taker_fee(&self) -> Decimal {
481        self.taker_fee
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use rstest::rstest;
488    use rust_decimal_macros::dec;
489    use ustr::Ustr;
490
491    use crate::{
492        enums::{AssetClass, InstrumentClass, OptionKind},
493        identifiers::{InstrumentId, Symbol},
494        instruments::{Instrument, OptionContract, stubs::*},
495        types::{Currency, Price, Quantity},
496    };
497
498    #[rstest]
499    fn test_trait_accessors(option_contract_appl: OptionContract) {
500        assert_eq!(
501            option_contract_appl.id(),
502            InstrumentId::from("AAPL211217C00150000.OPRA"),
503        );
504        assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
505        assert_eq!(
506            option_contract_appl.instrument_class(),
507            InstrumentClass::Option
508        );
509        assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
510        assert!(!option_contract_appl.is_inverse());
511        assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
512        assert_eq!(
513            option_contract_appl.strike_price(),
514            Some(Price::from("149.0"))
515        );
516        assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
517        assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
518        assert!(option_contract_appl.activation_ns().is_some());
519        assert!(option_contract_appl.expiration_ns().is_some());
520        assert_eq!(option_contract_appl.size_precision(), 0);
521        assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
522        assert_eq!(
523            option_contract_appl.min_quantity(),
524            Some(Quantity::from("1"))
525        );
526    }
527
528    #[rstest]
529    fn test_new_checked_price_precision_mismatch() {
530        let result = OptionContract::new_checked(
531            InstrumentId::from("TEST.OPRA"),
532            Symbol::from("TEST"),
533            AssetClass::Equity,
534            Some(Ustr::from("GMNI")),
535            Ustr::from("AAPL"),
536            OptionKind::Call,
537            Price::from("150.0"),
538            Currency::USD(),
539            0.into(),
540            0.into(),
541            4, // mismatch
542            Price::from("0.01"),
543            Quantity::from(1),
544            Quantity::from(1),
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    fn test_new_checked_zero_multiplier() {
563        let result = OptionContract::new_checked(
564            InstrumentId::from("TEST.OPRA"),
565            Symbol::from("TEST"),
566            AssetClass::Equity,
567            Some(Ustr::from("GMNI")),
568            Ustr::from("AAPL"),
569            OptionKind::Call,
570            Price::from("150.0"),
571            Currency::USD(),
572            0.into(),
573            0.into(),
574            2,
575            Price::from("0.01"),
576            Quantity::from("0"), // zero multiplier
577            Quantity::from(1),
578            None,
579            None,
580            None,
581            None,
582            None,
583            None,
584            None,
585            None,
586            None,
587            None,
588            0.into(),
589            0.into(),
590        );
591        assert!(result.is_err());
592    }
593
594    #[rstest]
595    fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
596        let json = serde_json::to_string(&option_contract_appl).unwrap();
597        let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
598        assert_eq!(option_contract_appl, deserialized);
599    }
600
601    #[rstest]
602    fn test_builder_matches_new_checked() {
603        let positional = OptionContract::new_checked(
604            InstrumentId::from("AAPL211217C00150000.OPRA"),
605            Symbol::from("AAPL211217C00150000"),
606            AssetClass::Equity,
607            Some(Ustr::from("GMNI")),
608            Ustr::from("AAPL"),
609            OptionKind::Call,
610            Price::from("149.0"),
611            Currency::USD(),
612            1.into(),
613            2.into(),
614            2,
615            Price::from("0.01"),
616            Quantity::from(10),
617            Quantity::from(5),
618            Some(Quantity::from("100")),
619            Some(Quantity::from("1")),
620            Some(Price::from("999.0")),
621            Some(Price::from("1.0")),
622            Some(dec!(0.01)),
623            Some(dec!(0.02)),
624            Some(dec!(0.0002)),
625            Some(dec!(0.0004)),
626            None,
627            None,
628            3.into(),
629            4.into(),
630        )
631        .unwrap();
632
633        let built = OptionContract::builder()
634            .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
635            .raw_symbol(Symbol::from("AAPL211217C00150000"))
636            .asset_class(AssetClass::Equity)
637            .exchange(Ustr::from("GMNI"))
638            .underlying(Ustr::from("AAPL"))
639            .option_kind(OptionKind::Call)
640            .strike_price(Price::from("149.0"))
641            .currency(Currency::USD())
642            .activation_ns(1.into())
643            .expiration_ns(2.into())
644            .price_precision(2)
645            .price_increment(Price::from("0.01"))
646            .multiplier(Quantity::from(10))
647            .lot_size(Quantity::from(5))
648            .max_quantity(Quantity::from("100"))
649            .min_quantity(Quantity::from("1"))
650            .max_price(Price::from("999.0"))
651            .min_price(Price::from("1.0"))
652            .margin_init(dec!(0.01))
653            .margin_maint(dec!(0.02))
654            .maker_fee(dec!(0.0002))
655            .taker_fee(dec!(0.0004))
656            .ts_event(3.into())
657            .ts_init(4.into())
658            .build()
659            .unwrap();
660
661        assert_eq!(
662            serde_json::to_value(&positional).unwrap(),
663            serde_json::to_value(&built).unwrap(),
664        );
665    }
666}