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, 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 maximum allowable order quantity.
90    pub max_quantity: Option<Quantity>,
91    /// The minimum allowable order quantity.
92    pub min_quantity: Option<Quantity>,
93    /// The maximum allowable quoted price.
94    pub max_price: Option<Price>,
95    /// The minimum allowable quoted price.
96    pub min_price: Option<Price>,
97    /// The registered variable tick scheme name.
98    pub tick_scheme: Option<Ustr>,
99    /// Additional instrument metadata as a JSON-serializable dictionary.
100    pub info: Option<Params>,
101    /// UNIX timestamp (nanoseconds) when the data event occurred.
102    pub ts_event: UnixNanos,
103    /// UNIX timestamp (nanoseconds) when the data object was initialized.
104    pub ts_init: UnixNanos,
105}
106
107#[bon::bon]
108impl OptionContract {
109    #[expect(clippy::too_many_arguments)]
110    fn new_checked(
111        instrument_id: InstrumentId,
112        raw_symbol: Symbol,
113        asset_class: AssetClass,
114        exchange: Option<Ustr>,
115        underlying: Ustr,
116        option_kind: OptionKind,
117        strike_price: Price,
118        currency: Currency,
119        activation_ns: UnixNanos,
120        expiration_ns: UnixNanos,
121        price_precision: u8,
122        price_increment: Price,
123        multiplier: Quantity,
124        lot_size: Quantity,
125        max_quantity: Option<Quantity>,
126        min_quantity: Option<Quantity>,
127        max_price: Option<Price>,
128        min_price: Option<Price>,
129        margin_init: Option<Decimal>,
130        margin_maint: Option<Decimal>,
131        tick_scheme: Option<Ustr>,
132        info: Option<Params>,
133        ts_event: UnixNanos,
134        ts_init: UnixNanos,
135    ) -> CorrectnessResult<Self> {
136        check_valid_string_ascii_optional(exchange, stringify!(exchange))?;
137        check_valid_string_ascii(underlying, stringify!(underlying))?;
138        check_equal_u8(
139            price_precision,
140            price_increment.precision,
141            stringify!(price_precision),
142            stringify!(price_increment.precision),
143        )?;
144        check_positive_price(price_increment, stringify!(price_increment))?;
145        check_positive_price(strike_price, stringify!(strike_price))?;
146        check_tick_scheme(tick_scheme)?;
147        check_positive_quantity(multiplier, stringify!(multiplier))?;
148        check_positive_quantity(lot_size, stringify!(lot_size))?;
149
150        Ok(Self {
151            id: instrument_id,
152            raw_symbol,
153            asset_class,
154            exchange,
155            underlying,
156            option_kind,
157            activation_ns,
158            expiration_ns,
159            strike_price,
160            currency,
161            price_precision,
162            price_increment,
163            size_precision: 0,
164            size_increment: Quantity::from(1),
165            multiplier,
166            lot_size,
167            margin_init: margin_init.unwrap_or_default(),
168            margin_maint: margin_maint.unwrap_or_default(),
169            tick_scheme,
170            info,
171            max_quantity,
172            min_quantity: Some(min_quantity.unwrap_or(1.into())),
173            max_price,
174            min_price,
175            ts_event,
176            ts_init,
177        })
178    }
179
180    /// Returns a fluent builder for a [`OptionContract`] instance.
181    ///
182    /// Required fields are enforced at compile time; optional fields can be omitted and use the
183    /// same defaults as checked construction. The same correctness checks run on `build`.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if any input validation fails.
188    #[builder(start_fn = builder, finish_fn = build)]
189    pub fn build_checked(
190        instrument_id: InstrumentId,
191        raw_symbol: Symbol,
192        asset_class: AssetClass,
193        exchange: Option<Ustr>,
194        underlying: Ustr,
195        option_kind: OptionKind,
196        strike_price: Price,
197        currency: Currency,
198        activation_ns: UnixNanos,
199        expiration_ns: UnixNanos,
200        price_precision: u8,
201        price_increment: Price,
202        multiplier: Quantity,
203        lot_size: Quantity,
204        max_quantity: Option<Quantity>,
205        min_quantity: Option<Quantity>,
206        max_price: Option<Price>,
207        min_price: Option<Price>,
208        margin_init: Option<Decimal>,
209        margin_maint: Option<Decimal>,
210        tick_scheme: Option<Ustr>,
211        info: Option<Params>,
212        ts_event: UnixNanos,
213        ts_init: UnixNanos,
214    ) -> CorrectnessResult<Self> {
215        Self::new_checked(
216            instrument_id,
217            raw_symbol,
218            asset_class,
219            exchange,
220            underlying,
221            option_kind,
222            strike_price,
223            currency,
224            activation_ns,
225            expiration_ns,
226            price_precision,
227            price_increment,
228            multiplier,
229            lot_size,
230            max_quantity,
231            min_quantity,
232            max_price,
233            min_price,
234            margin_init,
235            margin_maint,
236            tick_scheme,
237            info,
238            ts_event,
239            ts_init,
240        )
241    }
242}
243
244impl PartialEq<Self> for OptionContract {
245    fn eq(&self, other: &Self) -> bool {
246        self.id == other.id
247    }
248}
249
250impl Eq for OptionContract {}
251
252impl Hash for OptionContract {
253    fn hash<H: Hasher>(&self, state: &mut H) {
254        self.id.hash(state);
255    }
256}
257
258impl Instrument for OptionContract {
259    fn into_any(self) -> InstrumentAny {
260        InstrumentAny::OptionContract(self)
261    }
262
263    fn id(&self) -> InstrumentId {
264        self.id
265    }
266
267    fn raw_symbol(&self) -> Symbol {
268        self.raw_symbol
269    }
270
271    fn asset_class(&self) -> AssetClass {
272        self.asset_class
273    }
274
275    fn instrument_class(&self) -> InstrumentClass {
276        InstrumentClass::Option
277    }
278    fn underlying(&self) -> Option<Ustr> {
279        Some(self.underlying)
280    }
281
282    fn base_currency(&self) -> Option<Currency> {
283        None
284    }
285
286    fn quote_currency(&self) -> Currency {
287        self.currency
288    }
289
290    fn settlement_currency(&self) -> Currency {
291        self.currency
292    }
293
294    fn isin(&self) -> Option<Ustr> {
295        None
296    }
297
298    fn option_kind(&self) -> Option<OptionKind> {
299        Some(self.option_kind)
300    }
301
302    fn exchange(&self) -> Option<Ustr> {
303        self.exchange
304    }
305
306    fn strike_price(&self) -> Option<Price> {
307        Some(self.strike_price)
308    }
309
310    fn activation_ns(&self) -> Option<UnixNanos> {
311        Some(self.activation_ns)
312    }
313
314    fn expiration_ns(&self) -> Option<UnixNanos> {
315        Some(self.expiration_ns)
316    }
317
318    fn is_inverse(&self) -> bool {
319        false
320    }
321
322    fn price_precision(&self) -> u8 {
323        self.price_precision
324    }
325
326    fn size_precision(&self) -> u8 {
327        0
328    }
329
330    fn price_increment(&self) -> Price {
331        self.price_increment
332    }
333
334    fn size_increment(&self) -> Quantity {
335        Quantity::from(1)
336    }
337
338    fn multiplier(&self) -> Quantity {
339        self.multiplier
340    }
341
342    fn lot_size(&self) -> Option<Quantity> {
343        Some(self.lot_size)
344    }
345
346    fn max_quantity(&self) -> Option<Quantity> {
347        self.max_quantity
348    }
349
350    fn min_quantity(&self) -> Option<Quantity> {
351        self.min_quantity
352    }
353
354    fn max_notional(&self) -> Option<Money> {
355        None
356    }
357
358    fn min_notional(&self) -> Option<Money> {
359        None
360    }
361
362    fn max_price(&self) -> Option<Price> {
363        self.max_price
364    }
365
366    fn min_price(&self) -> Option<Price> {
367        self.min_price
368    }
369
370    fn tick_scheme(&self) -> Option<Ustr> {
371        self.tick_scheme
372    }
373
374    fn info(&self) -> Option<&Params> {
375        self.info.as_ref()
376    }
377
378    fn ts_event(&self) -> UnixNanos {
379        self.ts_event
380    }
381
382    fn ts_init(&self) -> UnixNanos {
383        self.ts_init
384    }
385
386    fn margin_init(&self) -> Decimal {
387        self.margin_init
388    }
389
390    fn margin_maint(&self) -> Decimal {
391        self.margin_maint
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use rstest::rstest;
398    use rust_decimal_macros::dec;
399    use ustr::Ustr;
400
401    use crate::{
402        enums::{AssetClass, InstrumentClass, OptionKind},
403        identifiers::{InstrumentId, Symbol},
404        instruments::{Instrument, OptionContract, stubs::*},
405        types::{Currency, Price, Quantity},
406    };
407
408    #[rstest]
409    fn test_trait_accessors(option_contract_appl: OptionContract) {
410        assert_eq!(
411            option_contract_appl.id(),
412            InstrumentId::from("AAPL211217C00150000.OPRA"),
413        );
414        assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
415        assert_eq!(
416            option_contract_appl.instrument_class(),
417            InstrumentClass::Option
418        );
419        assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
420        assert!(!option_contract_appl.is_inverse());
421        assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
422        assert_eq!(
423            option_contract_appl.strike_price(),
424            Some(Price::from("149.0"))
425        );
426        assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
427        assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
428        assert!(option_contract_appl.activation_ns().is_some());
429        assert!(option_contract_appl.expiration_ns().is_some());
430        assert_eq!(option_contract_appl.size_precision(), 0);
431        assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
432        assert_eq!(
433            option_contract_appl.min_quantity(),
434            Some(Quantity::from("1"))
435        );
436    }
437
438    #[rstest]
439    fn test_new_checked_price_precision_mismatch() {
440        let result = OptionContract::new_checked(
441            InstrumentId::from("TEST.OPRA"),
442            Symbol::from("TEST"),
443            AssetClass::Equity,
444            Some(Ustr::from("GMNI")),
445            Ustr::from("AAPL"),
446            OptionKind::Call,
447            Price::from("150.0"),
448            Currency::USD(),
449            0.into(),
450            0.into(),
451            4, // mismatch
452            Price::from("0.01"),
453            Quantity::from(1),
454            Quantity::from(1),
455            None,
456            None,
457            None,
458            None,
459            None,
460            None,
461            None,
462            None,
463            0.into(),
464            0.into(),
465        );
466        assert!(result.is_err());
467    }
468
469    #[rstest]
470    fn test_new_checked_zero_multiplier() {
471        let result = OptionContract::new_checked(
472            InstrumentId::from("TEST.OPRA"),
473            Symbol::from("TEST"),
474            AssetClass::Equity,
475            Some(Ustr::from("GMNI")),
476            Ustr::from("AAPL"),
477            OptionKind::Call,
478            Price::from("150.0"),
479            Currency::USD(),
480            0.into(),
481            0.into(),
482            2,
483            Price::from("0.01"),
484            Quantity::from("0"), // zero multiplier
485            Quantity::from(1),
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            None,
494            0.into(),
495            0.into(),
496        );
497        assert!(result.is_err());
498    }
499
500    #[rstest]
501    #[case(Price::from("0"))]
502    #[case(Price::from("-1"))]
503    fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
504        let result = OptionContract::new_checked(
505            InstrumentId::from("TEST.OPRA"),
506            Symbol::from("TEST"),
507            AssetClass::Equity,
508            Some(Ustr::from("GMNI")),
509            Ustr::from("AAPL"),
510            OptionKind::Call,
511            strike_price,
512            Currency::USD(),
513            0.into(),
514            0.into(),
515            2,
516            Price::from("0.01"),
517            Quantity::from(1),
518            Quantity::from(1),
519            None,
520            None,
521            None,
522            None,
523            None,
524            None,
525            None,
526            None,
527            0.into(),
528            0.into(),
529        );
530
531        // Assert on the parameter name, not merely `is_err`: this constructor validates a
532        // dozen other fields, and a bare error check would pass if an unrelated one fired.
533        assert!(
534            result
535                .unwrap_err()
536                .to_string()
537                .contains("'strike_price' not positive")
538        );
539    }
540
541    #[rstest]
542    fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
543        let json = serde_json::to_string(&option_contract_appl).unwrap();
544        let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
545        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
546    }
547
548    #[rstest]
549    fn test_builder_matches_new_checked() {
550        let positional = OptionContract::new_checked(
551            InstrumentId::from("AAPL211217C00150000.OPRA"),
552            Symbol::from("AAPL211217C00150000"),
553            AssetClass::Equity,
554            Some(Ustr::from("GMNI")),
555            Ustr::from("AAPL"),
556            OptionKind::Call,
557            Price::from("149.0"),
558            Currency::USD(),
559            1.into(),
560            2.into(),
561            2,
562            Price::from("0.01"),
563            Quantity::from(10),
564            Quantity::from(5),
565            Some(Quantity::from("100")),
566            Some(Quantity::from("1")),
567            Some(Price::from("999.0")),
568            Some(Price::from("1.0")),
569            Some(dec!(0.01)),
570            Some(dec!(0.02)),
571            None,
572            None,
573            3.into(),
574            4.into(),
575        )
576        .unwrap();
577
578        let built = OptionContract::builder()
579            .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
580            .raw_symbol(Symbol::from("AAPL211217C00150000"))
581            .asset_class(AssetClass::Equity)
582            .exchange(Ustr::from("GMNI"))
583            .underlying(Ustr::from("AAPL"))
584            .option_kind(OptionKind::Call)
585            .strike_price(Price::from("149.0"))
586            .currency(Currency::USD())
587            .activation_ns(1.into())
588            .expiration_ns(2.into())
589            .price_precision(2)
590            .price_increment(Price::from("0.01"))
591            .multiplier(Quantity::from(10))
592            .lot_size(Quantity::from(5))
593            .max_quantity(Quantity::from("100"))
594            .min_quantity(Quantity::from("1"))
595            .max_price(Price::from("999.0"))
596            .min_price(Price::from("1.0"))
597            .margin_init(dec!(0.01))
598            .margin_maint(dec!(0.02))
599            .ts_event(3.into())
600            .ts_init(4.into())
601            .build()
602            .unwrap();
603
604        assert_eq!(
605            serde_json::to_value(&positional).unwrap(),
606            serde_json::to_value(&built).unwrap(),
607        );
608    }
609}