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