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.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_positive_price(strike_price, stringify!(strike_price))?;
161        check_tick_scheme(tick_scheme)?;
162        check_positive_quantity(multiplier, stringify!(multiplier))?;
163        check_positive_quantity(lot_size, stringify!(lot_size))?;
164
165        Ok(Self {
166            id: instrument_id,
167            raw_symbol,
168            asset_class,
169            exchange,
170            underlying,
171            option_kind,
172            activation_ns,
173            expiration_ns,
174            strike_price,
175            currency,
176            price_precision,
177            price_increment,
178            size_precision: 0,
179            size_increment: Quantity::from(1),
180            multiplier,
181            lot_size,
182            margin_init: margin_init.unwrap_or_default(),
183            margin_maint: margin_maint.unwrap_or_default(),
184            maker_fee: maker_fee.unwrap_or_default(),
185            taker_fee: taker_fee.unwrap_or_default(),
186            tick_scheme,
187            info,
188            max_quantity,
189            min_quantity: Some(min_quantity.unwrap_or(1.into())),
190            max_price,
191            min_price,
192            ts_event,
193            ts_init,
194        })
195    }
196
197    /// Creates a new [`OptionContract`] instance.
198    ///
199    /// # Panics
200    ///
201    /// Panics if any input parameter is invalid (see `new_checked`).
202    #[expect(clippy::too_many_arguments)]
203    #[must_use]
204    pub fn new(
205        instrument_id: InstrumentId,
206        raw_symbol: Symbol,
207        asset_class: AssetClass,
208        exchange: Option<Ustr>,
209        underlying: Ustr,
210        option_kind: OptionKind,
211        strike_price: Price,
212        currency: Currency,
213        activation_ns: UnixNanos,
214        expiration_ns: UnixNanos,
215        price_precision: u8,
216        price_increment: Price,
217        multiplier: Quantity,
218        lot_size: Quantity,
219        max_quantity: Option<Quantity>,
220        min_quantity: Option<Quantity>,
221        max_price: Option<Price>,
222        min_price: Option<Price>,
223        margin_init: Option<Decimal>,
224        margin_maint: Option<Decimal>,
225        maker_fee: Option<Decimal>,
226        taker_fee: Option<Decimal>,
227        tick_scheme: Option<Ustr>,
228        info: Option<Params>,
229        ts_event: UnixNanos,
230        ts_init: UnixNanos,
231    ) -> Self {
232        Self::new_checked(
233            instrument_id,
234            raw_symbol,
235            asset_class,
236            exchange,
237            underlying,
238            option_kind,
239            strike_price,
240            currency,
241            activation_ns,
242            expiration_ns,
243            price_precision,
244            price_increment,
245            multiplier,
246            lot_size,
247            max_quantity,
248            min_quantity,
249            max_price,
250            min_price,
251            margin_init,
252            margin_maint,
253            maker_fee,
254            taker_fee,
255            tick_scheme,
256            info,
257            ts_event,
258            ts_init,
259        )
260        .expect_display(FAILED)
261    }
262
263    /// Returns a fluent builder for a [`OptionContract`] instance.
264    ///
265    /// Required fields are enforced at compile time; optional fields can be omitted and default
266    /// the same way they do in [`OptionContract::new_checked`], which the builder calls so the same
267    /// correctness checks run on `build`.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if any input validation fails (see [`OptionContract::new_checked`]).
272    #[builder(start_fn = builder, finish_fn = build)]
273    pub fn build_checked(
274        instrument_id: InstrumentId,
275        raw_symbol: Symbol,
276        asset_class: AssetClass,
277        exchange: Option<Ustr>,
278        underlying: Ustr,
279        option_kind: OptionKind,
280        strike_price: Price,
281        currency: Currency,
282        activation_ns: UnixNanos,
283        expiration_ns: UnixNanos,
284        price_precision: u8,
285        price_increment: Price,
286        multiplier: Quantity,
287        lot_size: Quantity,
288        max_quantity: Option<Quantity>,
289        min_quantity: Option<Quantity>,
290        max_price: Option<Price>,
291        min_price: Option<Price>,
292        margin_init: Option<Decimal>,
293        margin_maint: Option<Decimal>,
294        maker_fee: Option<Decimal>,
295        taker_fee: Option<Decimal>,
296        tick_scheme: Option<Ustr>,
297        info: Option<Params>,
298        ts_event: UnixNanos,
299        ts_init: UnixNanos,
300    ) -> CorrectnessResult<Self> {
301        Self::new_checked(
302            instrument_id,
303            raw_symbol,
304            asset_class,
305            exchange,
306            underlying,
307            option_kind,
308            strike_price,
309            currency,
310            activation_ns,
311            expiration_ns,
312            price_precision,
313            price_increment,
314            multiplier,
315            lot_size,
316            max_quantity,
317            min_quantity,
318            max_price,
319            min_price,
320            margin_init,
321            margin_maint,
322            maker_fee,
323            taker_fee,
324            tick_scheme,
325            info,
326            ts_event,
327            ts_init,
328        )
329    }
330}
331
332impl PartialEq<Self> for OptionContract {
333    fn eq(&self, other: &Self) -> bool {
334        self.id == other.id
335    }
336}
337
338impl Eq for OptionContract {}
339
340impl Hash for OptionContract {
341    fn hash<H: Hasher>(&self, state: &mut H) {
342        self.id.hash(state);
343    }
344}
345
346impl Instrument for OptionContract {
347    fn tick_scheme(&self) -> Option<Ustr> {
348        self.tick_scheme
349    }
350    fn into_any(self) -> InstrumentAny {
351        InstrumentAny::OptionContract(self)
352    }
353
354    fn id(&self) -> InstrumentId {
355        self.id
356    }
357
358    fn raw_symbol(&self) -> Symbol {
359        self.raw_symbol
360    }
361
362    fn asset_class(&self) -> AssetClass {
363        self.asset_class
364    }
365
366    fn instrument_class(&self) -> InstrumentClass {
367        InstrumentClass::Option
368    }
369    fn underlying(&self) -> Option<Ustr> {
370        Some(self.underlying)
371    }
372
373    fn base_currency(&self) -> Option<Currency> {
374        None
375    }
376
377    fn quote_currency(&self) -> Currency {
378        self.currency
379    }
380
381    fn settlement_currency(&self) -> Currency {
382        self.currency
383    }
384
385    fn isin(&self) -> Option<Ustr> {
386        None
387    }
388
389    fn option_kind(&self) -> Option<OptionKind> {
390        Some(self.option_kind)
391    }
392
393    fn exchange(&self) -> Option<Ustr> {
394        self.exchange
395    }
396
397    fn strike_price(&self) -> Option<Price> {
398        Some(self.strike_price)
399    }
400
401    fn activation_ns(&self) -> Option<UnixNanos> {
402        Some(self.activation_ns)
403    }
404
405    fn expiration_ns(&self) -> Option<UnixNanos> {
406        Some(self.expiration_ns)
407    }
408
409    fn is_inverse(&self) -> bool {
410        false
411    }
412
413    fn price_precision(&self) -> u8 {
414        self.price_precision
415    }
416
417    fn size_precision(&self) -> u8 {
418        0
419    }
420
421    fn price_increment(&self) -> Price {
422        self.price_increment
423    }
424
425    fn size_increment(&self) -> Quantity {
426        Quantity::from(1)
427    }
428
429    fn multiplier(&self) -> Quantity {
430        self.multiplier
431    }
432
433    fn lot_size(&self) -> Option<Quantity> {
434        Some(self.lot_size)
435    }
436
437    fn max_quantity(&self) -> Option<Quantity> {
438        self.max_quantity
439    }
440
441    fn min_quantity(&self) -> Option<Quantity> {
442        self.min_quantity
443    }
444
445    fn max_notional(&self) -> Option<Money> {
446        None
447    }
448
449    fn min_notional(&self) -> Option<Money> {
450        None
451    }
452
453    fn max_price(&self) -> Option<Price> {
454        self.max_price
455    }
456
457    fn min_price(&self) -> Option<Price> {
458        self.min_price
459    }
460
461    fn ts_event(&self) -> UnixNanos {
462        self.ts_event
463    }
464
465    fn ts_init(&self) -> UnixNanos {
466        self.ts_init
467    }
468
469    fn margin_init(&self) -> Decimal {
470        self.margin_init
471    }
472
473    fn margin_maint(&self) -> Decimal {
474        self.margin_maint
475    }
476
477    fn maker_fee(&self) -> Decimal {
478        self.maker_fee
479    }
480
481    fn taker_fee(&self) -> Decimal {
482        self.taker_fee
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use rstest::rstest;
489    use rust_decimal_macros::dec;
490    use ustr::Ustr;
491
492    use crate::{
493        enums::{AssetClass, InstrumentClass, OptionKind},
494        identifiers::{InstrumentId, Symbol},
495        instruments::{Instrument, OptionContract, stubs::*},
496        types::{Currency, Price, Quantity},
497    };
498
499    #[rstest]
500    fn test_trait_accessors(option_contract_appl: OptionContract) {
501        assert_eq!(
502            option_contract_appl.id(),
503            InstrumentId::from("AAPL211217C00150000.OPRA"),
504        );
505        assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
506        assert_eq!(
507            option_contract_appl.instrument_class(),
508            InstrumentClass::Option
509        );
510        assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
511        assert!(!option_contract_appl.is_inverse());
512        assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
513        assert_eq!(
514            option_contract_appl.strike_price(),
515            Some(Price::from("149.0"))
516        );
517        assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
518        assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
519        assert!(option_contract_appl.activation_ns().is_some());
520        assert!(option_contract_appl.expiration_ns().is_some());
521        assert_eq!(option_contract_appl.size_precision(), 0);
522        assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
523        assert_eq!(
524            option_contract_appl.min_quantity(),
525            Some(Quantity::from("1"))
526        );
527    }
528
529    #[rstest]
530    fn test_new_checked_price_precision_mismatch() {
531        let result = OptionContract::new_checked(
532            InstrumentId::from("TEST.OPRA"),
533            Symbol::from("TEST"),
534            AssetClass::Equity,
535            Some(Ustr::from("GMNI")),
536            Ustr::from("AAPL"),
537            OptionKind::Call,
538            Price::from("150.0"),
539            Currency::USD(),
540            0.into(),
541            0.into(),
542            4, // mismatch
543            Price::from("0.01"),
544            Quantity::from(1),
545            Quantity::from(1),
546            None,
547            None,
548            None,
549            None,
550            None,
551            None,
552            None,
553            None,
554            None,
555            None,
556            0.into(),
557            0.into(),
558        );
559        assert!(result.is_err());
560    }
561
562    #[rstest]
563    fn test_new_checked_zero_multiplier() {
564        let result = OptionContract::new_checked(
565            InstrumentId::from("TEST.OPRA"),
566            Symbol::from("TEST"),
567            AssetClass::Equity,
568            Some(Ustr::from("GMNI")),
569            Ustr::from("AAPL"),
570            OptionKind::Call,
571            Price::from("150.0"),
572            Currency::USD(),
573            0.into(),
574            0.into(),
575            2,
576            Price::from("0.01"),
577            Quantity::from("0"), // zero multiplier
578            Quantity::from(1),
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        assert!(result.is_err());
593    }
594
595    #[rstest]
596    #[case(Price::from("0"))]
597    #[case(Price::from("-1"))]
598    fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
599        let result = OptionContract::new_checked(
600            InstrumentId::from("TEST.OPRA"),
601            Symbol::from("TEST"),
602            AssetClass::Equity,
603            Some(Ustr::from("GMNI")),
604            Ustr::from("AAPL"),
605            OptionKind::Call,
606            strike_price,
607            Currency::USD(),
608            0.into(),
609            0.into(),
610            2,
611            Price::from("0.01"),
612            Quantity::from(1),
613            Quantity::from(1),
614            None,
615            None,
616            None,
617            None,
618            None,
619            None,
620            None,
621            None,
622            None,
623            None,
624            0.into(),
625            0.into(),
626        );
627
628        // Assert on the parameter name, not merely `is_err`: this constructor validates a
629        // dozen other fields, and a bare error check would pass if an unrelated one fired.
630        assert!(
631            result
632                .unwrap_err()
633                .to_string()
634                .contains("'strike_price' not positive")
635        );
636    }
637
638    #[rstest]
639    fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
640        let json = serde_json::to_string(&option_contract_appl).unwrap();
641        let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
642        assert_eq!(option_contract_appl, deserialized);
643    }
644
645    #[rstest]
646    fn test_builder_matches_new_checked() {
647        let positional = OptionContract::new_checked(
648            InstrumentId::from("AAPL211217C00150000.OPRA"),
649            Symbol::from("AAPL211217C00150000"),
650            AssetClass::Equity,
651            Some(Ustr::from("GMNI")),
652            Ustr::from("AAPL"),
653            OptionKind::Call,
654            Price::from("149.0"),
655            Currency::USD(),
656            1.into(),
657            2.into(),
658            2,
659            Price::from("0.01"),
660            Quantity::from(10),
661            Quantity::from(5),
662            Some(Quantity::from("100")),
663            Some(Quantity::from("1")),
664            Some(Price::from("999.0")),
665            Some(Price::from("1.0")),
666            Some(dec!(0.01)),
667            Some(dec!(0.02)),
668            Some(dec!(0.0002)),
669            Some(dec!(0.0004)),
670            None,
671            None,
672            3.into(),
673            4.into(),
674        )
675        .unwrap();
676
677        let built = OptionContract::builder()
678            .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
679            .raw_symbol(Symbol::from("AAPL211217C00150000"))
680            .asset_class(AssetClass::Equity)
681            .exchange(Ustr::from("GMNI"))
682            .underlying(Ustr::from("AAPL"))
683            .option_kind(OptionKind::Call)
684            .strike_price(Price::from("149.0"))
685            .currency(Currency::USD())
686            .activation_ns(1.into())
687            .expiration_ns(2.into())
688            .price_precision(2)
689            .price_increment(Price::from("0.01"))
690            .multiplier(Quantity::from(10))
691            .lot_size(Quantity::from(5))
692            .max_quantity(Quantity::from("100"))
693            .min_quantity(Quantity::from("1"))
694            .max_price(Price::from("999.0"))
695            .min_price(Price::from("1.0"))
696            .margin_init(dec!(0.01))
697            .margin_maint(dec!(0.02))
698            .maker_fee(dec!(0.0002))
699            .taker_fee(dec!(0.0004))
700            .ts_event(3.into())
701            .ts_init(4.into())
702            .build()
703            .unwrap();
704
705        assert_eq!(
706            serde_json::to_value(&positional).unwrap(),
707            serde_json::to_value(&built).unwrap(),
708        );
709    }
710}