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};
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    /// Additional instrument metadata as a JSON-serializable dictionary.
102    pub info: Option<Params>,
103    /// UNIX timestamp (nanoseconds) when the data event occurred.
104    pub ts_event: UnixNanos,
105    /// UNIX timestamp (nanoseconds) when the data object was initialized.
106    pub ts_init: UnixNanos,
107}
108
109impl OptionContract {
110    /// Creates a new [`OptionContract`] instance with correctness checking.
111    ///
112    /// # Notes
113    ///
114    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
115    /// # Errors
116    ///
117    /// Returns an error if any input validation fails.
118    #[expect(clippy::too_many_arguments)]
119    pub fn new_checked(
120        instrument_id: InstrumentId,
121        raw_symbol: Symbol,
122        asset_class: AssetClass,
123        exchange: Option<Ustr>,
124        underlying: Ustr,
125        option_kind: OptionKind,
126        strike_price: Price,
127        currency: Currency,
128        activation_ns: UnixNanos,
129        expiration_ns: UnixNanos,
130        price_precision: u8,
131        price_increment: Price,
132        multiplier: Quantity,
133        lot_size: Quantity,
134        max_quantity: Option<Quantity>,
135        min_quantity: Option<Quantity>,
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        info: Option<Params>,
143        ts_event: UnixNanos,
144        ts_init: UnixNanos,
145    ) -> CorrectnessResult<Self> {
146        check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
147        check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
148        check_equal_u8(
149            price_precision,
150            price_increment.precision,
151            stringify!(price_precision),
152            stringify!(price_increment.precision),
153        )?;
154        check_positive_price(price_increment, stringify!(price_increment))?;
155        check_positive_quantity(multiplier, stringify!(multiplier))?;
156        check_positive_quantity(lot_size, stringify!(lot_size))?;
157
158        Ok(Self {
159            id: instrument_id,
160            raw_symbol,
161            asset_class,
162            exchange,
163            underlying,
164            option_kind,
165            activation_ns,
166            expiration_ns,
167            strike_price,
168            currency,
169            price_precision,
170            price_increment,
171            size_precision: 0,
172            size_increment: Quantity::from(1),
173            multiplier,
174            lot_size,
175            margin_init: margin_init.unwrap_or_default(),
176            margin_maint: margin_maint.unwrap_or_default(),
177            maker_fee: maker_fee.unwrap_or_default(),
178            taker_fee: taker_fee.unwrap_or_default(),
179            info,
180            max_quantity,
181            min_quantity: Some(min_quantity.unwrap_or(1.into())),
182            max_price,
183            min_price,
184            ts_event,
185            ts_init,
186        })
187    }
188
189    /// Creates a new [`OptionContract`] instance.
190    ///
191    /// # Panics
192    ///
193    /// Panics if any input parameter is invalid (see `new_checked`).
194    #[expect(clippy::too_many_arguments)]
195    #[must_use]
196    pub fn new(
197        instrument_id: InstrumentId,
198        raw_symbol: Symbol,
199        asset_class: AssetClass,
200        exchange: Option<Ustr>,
201        underlying: Ustr,
202        option_kind: OptionKind,
203        strike_price: Price,
204        currency: Currency,
205        activation_ns: UnixNanos,
206        expiration_ns: UnixNanos,
207        price_precision: u8,
208        price_increment: Price,
209        multiplier: Quantity,
210        lot_size: Quantity,
211        max_quantity: Option<Quantity>,
212        min_quantity: Option<Quantity>,
213        max_price: Option<Price>,
214        min_price: Option<Price>,
215        margin_init: Option<Decimal>,
216        margin_maint: Option<Decimal>,
217        maker_fee: Option<Decimal>,
218        taker_fee: Option<Decimal>,
219        info: Option<Params>,
220        ts_event: UnixNanos,
221        ts_init: UnixNanos,
222    ) -> Self {
223        Self::new_checked(
224            instrument_id,
225            raw_symbol,
226            asset_class,
227            exchange,
228            underlying,
229            option_kind,
230            strike_price,
231            currency,
232            activation_ns,
233            expiration_ns,
234            price_precision,
235            price_increment,
236            multiplier,
237            lot_size,
238            max_quantity,
239            min_quantity,
240            max_price,
241            min_price,
242            margin_init,
243            margin_maint,
244            maker_fee,
245            taker_fee,
246            info,
247            ts_event,
248            ts_init,
249        )
250        .expect_display(FAILED)
251    }
252}
253
254impl PartialEq<Self> for OptionContract {
255    fn eq(&self, other: &Self) -> bool {
256        self.id == other.id
257    }
258}
259
260impl Eq for OptionContract {}
261
262impl Hash for OptionContract {
263    fn hash<H: Hasher>(&self, state: &mut H) {
264        self.id.hash(state);
265    }
266}
267
268impl Instrument for OptionContract {
269    fn into_any(self) -> InstrumentAny {
270        InstrumentAny::OptionContract(self)
271    }
272
273    fn id(&self) -> InstrumentId {
274        self.id
275    }
276
277    fn raw_symbol(&self) -> Symbol {
278        self.raw_symbol
279    }
280
281    fn asset_class(&self) -> AssetClass {
282        self.asset_class
283    }
284
285    fn instrument_class(&self) -> InstrumentClass {
286        InstrumentClass::Option
287    }
288    fn underlying(&self) -> Option<Ustr> {
289        Some(self.underlying)
290    }
291
292    fn base_currency(&self) -> Option<Currency> {
293        None
294    }
295
296    fn quote_currency(&self) -> Currency {
297        self.currency
298    }
299
300    fn settlement_currency(&self) -> Currency {
301        self.currency
302    }
303
304    fn isin(&self) -> Option<Ustr> {
305        None
306    }
307
308    fn option_kind(&self) -> Option<OptionKind> {
309        Some(self.option_kind)
310    }
311
312    fn exchange(&self) -> Option<Ustr> {
313        self.exchange
314    }
315
316    fn strike_price(&self) -> Option<Price> {
317        Some(self.strike_price)
318    }
319
320    fn activation_ns(&self) -> Option<UnixNanos> {
321        Some(self.activation_ns)
322    }
323
324    fn expiration_ns(&self) -> Option<UnixNanos> {
325        Some(self.expiration_ns)
326    }
327
328    fn is_inverse(&self) -> bool {
329        false
330    }
331
332    fn price_precision(&self) -> u8 {
333        self.price_precision
334    }
335
336    fn size_precision(&self) -> u8 {
337        0
338    }
339
340    fn price_increment(&self) -> Price {
341        self.price_increment
342    }
343
344    fn size_increment(&self) -> Quantity {
345        Quantity::from(1)
346    }
347
348    fn multiplier(&self) -> Quantity {
349        self.multiplier
350    }
351
352    fn lot_size(&self) -> Option<Quantity> {
353        Some(self.lot_size)
354    }
355
356    fn max_quantity(&self) -> Option<Quantity> {
357        self.max_quantity
358    }
359
360    fn min_quantity(&self) -> Option<Quantity> {
361        self.min_quantity
362    }
363
364    fn max_notional(&self) -> Option<Money> {
365        None
366    }
367
368    fn min_notional(&self) -> Option<Money> {
369        None
370    }
371
372    fn max_price(&self) -> Option<Price> {
373        self.max_price
374    }
375
376    fn min_price(&self) -> Option<Price> {
377        self.min_price
378    }
379
380    fn ts_event(&self) -> UnixNanos {
381        self.ts_event
382    }
383
384    fn ts_init(&self) -> UnixNanos {
385        self.ts_init
386    }
387
388    fn margin_init(&self) -> Decimal {
389        self.margin_init
390    }
391
392    fn margin_maint(&self) -> Decimal {
393        self.margin_maint
394    }
395
396    fn maker_fee(&self) -> Decimal {
397        self.maker_fee
398    }
399
400    fn taker_fee(&self) -> Decimal {
401        self.taker_fee
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use rstest::rstest;
408    use ustr::Ustr;
409
410    use crate::{
411        enums::{AssetClass, InstrumentClass, OptionKind},
412        identifiers::{InstrumentId, Symbol},
413        instruments::{Instrument, OptionContract, stubs::*},
414        types::{Currency, Price, Quantity},
415    };
416
417    #[rstest]
418    fn test_trait_accessors(option_contract_appl: OptionContract) {
419        assert_eq!(
420            option_contract_appl.id(),
421            InstrumentId::from("AAPL211217C00150000.OPRA"),
422        );
423        assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
424        assert_eq!(
425            option_contract_appl.instrument_class(),
426            InstrumentClass::Option
427        );
428        assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
429        assert!(!option_contract_appl.is_inverse());
430        assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
431        assert_eq!(
432            option_contract_appl.strike_price(),
433            Some(Price::from("149.0"))
434        );
435        assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
436        assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
437        assert!(option_contract_appl.activation_ns().is_some());
438        assert!(option_contract_appl.expiration_ns().is_some());
439        assert_eq!(option_contract_appl.size_precision(), 0);
440        assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
441        assert_eq!(
442            option_contract_appl.min_quantity(),
443            Some(Quantity::from("1"))
444        );
445    }
446
447    #[rstest]
448    fn test_new_checked_price_precision_mismatch() {
449        let result = OptionContract::new_checked(
450            InstrumentId::from("TEST.OPRA"),
451            Symbol::from("TEST"),
452            AssetClass::Equity,
453            Some(Ustr::from("GMNI")),
454            Ustr::from("AAPL"),
455            OptionKind::Call,
456            Price::from("150.0"),
457            Currency::USD(),
458            0.into(),
459            0.into(),
460            4, // mismatch
461            Price::from("0.01"),
462            Quantity::from(1),
463            Quantity::from(1),
464            None,
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471            None,
472            None,
473            0.into(),
474            0.into(),
475        );
476        assert!(result.is_err());
477    }
478
479    #[rstest]
480    fn test_new_checked_zero_multiplier() {
481        let result = OptionContract::new_checked(
482            InstrumentId::from("TEST.OPRA"),
483            Symbol::from("TEST"),
484            AssetClass::Equity,
485            Some(Ustr::from("GMNI")),
486            Ustr::from("AAPL"),
487            OptionKind::Call,
488            Price::from("150.0"),
489            Currency::USD(),
490            0.into(),
491            0.into(),
492            2,
493            Price::from("0.01"),
494            Quantity::from("0"), // zero multiplier
495            Quantity::from(1),
496            None,
497            None,
498            None,
499            None,
500            None,
501            None,
502            None,
503            None,
504            None,
505            0.into(),
506            0.into(),
507        );
508        assert!(result.is_err());
509    }
510
511    #[rstest]
512    fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
513        let json = serde_json::to_string(&option_contract_appl).unwrap();
514        let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
515        assert_eq!(option_contract_appl, deserialized);
516    }
517}