Skip to main content

nautilus_model/instruments/
option_spread.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 spread 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 OptionSpread {
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 spread 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 strategy type of the spread.
64    pub strategy_type: Ustr,
65    /// UNIX timestamp (nanoseconds) for contract activation.
66    pub activation_ns: UnixNanos,
67    /// UNIX timestamp (nanoseconds) for contract expiration.
68    pub expiration_ns: UnixNanos,
69    /// The option spread currency.
70    pub currency: Currency,
71    /// The price decimal precision.
72    pub price_precision: u8,
73    /// The minimum price increment (tick size).
74    pub price_increment: Price,
75    /// The minimum size increment.
76    pub size_increment: Quantity,
77    /// The trading size decimal precision.
78    pub size_precision: u8,
79    /// The option multiplier.
80    pub multiplier: Quantity,
81    /// The rounded lot unit size (standard/board).
82    pub lot_size: Quantity,
83    /// The initial (order) margin requirement in percentage of order value.
84    pub margin_init: Decimal,
85    /// The maintenance (position) margin in percentage of position value.
86    pub margin_maint: Decimal,
87    /// The fee rate for liquidity makers as a percentage of order value.
88    pub maker_fee: Decimal,
89    /// The fee rate for liquidity takers as a percentage of order value.
90    pub taker_fee: Decimal,
91    /// The maximum allowable order quantity.
92    pub max_quantity: Option<Quantity>,
93    /// The minimum allowable order quantity.
94    pub min_quantity: Option<Quantity>,
95    /// The maximum allowable quoted price.
96    pub max_price: Option<Price>,
97    /// The minimum allowable quoted price.
98    pub min_price: Option<Price>,
99    /// The registered variable tick scheme name.
100    pub tick_scheme: Option<Ustr>,
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
109#[bon::bon]
110impl OptionSpread {
111    /// Creates a new [`OptionSpread`] instance with correctness checking.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if any input validation fails.
116    ///
117    /// # Notes
118    ///
119    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
120    #[expect(clippy::too_many_arguments)]
121    pub fn new_checked(
122        instrument_id: InstrumentId,
123        raw_symbol: Symbol,
124        asset_class: AssetClass,
125        exchange: Option<Ustr>,
126        underlying: Ustr,
127        strategy_type: Ustr,
128        activation_ns: UnixNanos,
129        expiration_ns: UnixNanos,
130        currency: Currency,
131        price_precision: u8,
132        price_increment: Price,
133        multiplier: Quantity,
134        lot_size: Quantity,
135        max_quantity: Option<Quantity>,
136        min_quantity: Option<Quantity>,
137        max_price: Option<Price>,
138        min_price: Option<Price>,
139        margin_init: Option<Decimal>,
140        margin_maint: Option<Decimal>,
141        maker_fee: Option<Decimal>,
142        taker_fee: Option<Decimal>,
143        tick_scheme: Option<Ustr>,
144        info: Option<Params>,
145        ts_event: UnixNanos,
146        ts_init: UnixNanos,
147    ) -> CorrectnessResult<Self> {
148        check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
149        check_valid_string_ascii(strategy_type.as_str(), stringify!(strategy_type))?;
150        check_equal_u8(
151            price_precision,
152            price_increment.precision,
153            stringify!(price_precision),
154            stringify!(price_increment.precision),
155        )?;
156        check_positive_price(price_increment, stringify!(price_increment))?;
157        check_tick_scheme(tick_scheme)?;
158        check_positive_quantity(multiplier, stringify!(multiplier))?;
159        check_positive_quantity(lot_size, stringify!(lot_size))?;
160
161        Ok(Self {
162            id: instrument_id,
163            raw_symbol,
164            asset_class,
165            exchange,
166            underlying,
167            strategy_type,
168            activation_ns,
169            expiration_ns,
170            currency,
171            price_precision,
172            price_increment,
173            size_precision: 0,
174            size_increment: Quantity::from("1"),
175            multiplier,
176            lot_size,
177            margin_init: margin_init.unwrap_or_default(),
178            margin_maint: margin_maint.unwrap_or_default(),
179            maker_fee: maker_fee.unwrap_or_default(),
180            taker_fee: taker_fee.unwrap_or_default(),
181            max_quantity,
182            min_quantity: Some(min_quantity.unwrap_or(1.into())),
183            max_price,
184            min_price,
185            tick_scheme,
186            info,
187            ts_event,
188            ts_init,
189        })
190    }
191
192    /// Creates a new [`OptionSpread`] instance.
193    ///
194    /// # Panics
195    ///
196    /// Panics if any input parameter is invalid (see `new_checked`).
197    #[expect(clippy::too_many_arguments)]
198    #[must_use]
199    pub fn new(
200        instrument_id: InstrumentId,
201        raw_symbol: Symbol,
202        asset_class: AssetClass,
203        exchange: Option<Ustr>,
204        underlying: Ustr,
205        strategy_type: Ustr,
206        activation_ns: UnixNanos,
207        expiration_ns: UnixNanos,
208        currency: Currency,
209        price_precision: u8,
210        price_increment: Price,
211        multiplier: Quantity,
212        lot_size: Quantity,
213        max_quantity: Option<Quantity>,
214        min_quantity: Option<Quantity>,
215        max_price: Option<Price>,
216        min_price: Option<Price>,
217        margin_init: Option<Decimal>,
218        margin_maint: Option<Decimal>,
219        maker_fee: Option<Decimal>,
220        taker_fee: Option<Decimal>,
221        tick_scheme: Option<Ustr>,
222        info: Option<Params>,
223        ts_event: UnixNanos,
224        ts_init: UnixNanos,
225    ) -> Self {
226        Self::new_checked(
227            instrument_id,
228            raw_symbol,
229            asset_class,
230            exchange,
231            underlying,
232            strategy_type,
233            activation_ns,
234            expiration_ns,
235            currency,
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        .expect_display(FAILED)
254    }
255
256    /// Returns a fluent builder for a [`OptionSpread`] instance.
257    ///
258    /// Required fields are enforced at compile time; optional fields can be omitted and default
259    /// the same way they do in [`OptionSpread::new_checked`], which the builder calls so the same
260    /// correctness checks run on `build`.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if any input validation fails (see [`OptionSpread::new_checked`]).
265    #[builder(start_fn = builder, finish_fn = build)]
266    pub fn build_checked(
267        instrument_id: InstrumentId,
268        raw_symbol: Symbol,
269        asset_class: AssetClass,
270        exchange: Option<Ustr>,
271        underlying: Ustr,
272        strategy_type: Ustr,
273        activation_ns: UnixNanos,
274        expiration_ns: UnixNanos,
275        currency: Currency,
276        price_precision: u8,
277        price_increment: Price,
278        multiplier: Quantity,
279        lot_size: Quantity,
280        max_quantity: Option<Quantity>,
281        min_quantity: Option<Quantity>,
282        max_price: Option<Price>,
283        min_price: Option<Price>,
284        margin_init: Option<Decimal>,
285        margin_maint: Option<Decimal>,
286        maker_fee: Option<Decimal>,
287        taker_fee: Option<Decimal>,
288        tick_scheme: Option<Ustr>,
289        info: Option<Params>,
290        ts_event: UnixNanos,
291        ts_init: UnixNanos,
292    ) -> CorrectnessResult<Self> {
293        Self::new_checked(
294            instrument_id,
295            raw_symbol,
296            asset_class,
297            exchange,
298            underlying,
299            strategy_type,
300            activation_ns,
301            expiration_ns,
302            currency,
303            price_precision,
304            price_increment,
305            multiplier,
306            lot_size,
307            max_quantity,
308            min_quantity,
309            max_price,
310            min_price,
311            margin_init,
312            margin_maint,
313            maker_fee,
314            taker_fee,
315            tick_scheme,
316            info,
317            ts_event,
318            ts_init,
319        )
320    }
321}
322
323impl PartialEq<Self> for OptionSpread {
324    fn eq(&self, other: &Self) -> bool {
325        self.id == other.id
326    }
327}
328
329impl Eq for OptionSpread {}
330
331impl Hash for OptionSpread {
332    fn hash<H: Hasher>(&self, state: &mut H) {
333        self.id.hash(state);
334    }
335}
336
337impl Instrument for OptionSpread {
338    fn tick_scheme(&self) -> Option<Ustr> {
339        self.tick_scheme
340    }
341    fn into_any(self) -> InstrumentAny {
342        InstrumentAny::OptionSpread(self)
343    }
344
345    fn id(&self) -> InstrumentId {
346        self.id
347    }
348
349    fn raw_symbol(&self) -> Symbol {
350        self.raw_symbol
351    }
352
353    fn asset_class(&self) -> AssetClass {
354        self.asset_class
355    }
356
357    fn instrument_class(&self) -> InstrumentClass {
358        InstrumentClass::OptionSpread
359    }
360    fn underlying(&self) -> Option<Ustr> {
361        Some(self.underlying)
362    }
363
364    fn base_currency(&self) -> Option<Currency> {
365        None
366    }
367
368    fn quote_currency(&self) -> Currency {
369        self.currency
370    }
371
372    fn settlement_currency(&self) -> Currency {
373        self.currency
374    }
375
376    fn isin(&self) -> Option<Ustr> {
377        None
378    }
379
380    fn option_kind(&self) -> Option<OptionKind> {
381        None
382    }
383
384    fn exchange(&self) -> Option<Ustr> {
385        self.exchange
386    }
387
388    fn strike_price(&self) -> Option<Price> {
389        None
390    }
391
392    fn strategy_type(&self) -> Option<Ustr> {
393        Some(self.strategy_type)
394    }
395
396    fn activation_ns(&self) -> Option<UnixNanos> {
397        Some(self.activation_ns)
398    }
399
400    fn expiration_ns(&self) -> Option<UnixNanos> {
401        Some(self.expiration_ns)
402    }
403
404    fn is_inverse(&self) -> bool {
405        false
406    }
407
408    fn price_precision(&self) -> u8 {
409        self.price_precision
410    }
411
412    fn size_precision(&self) -> u8 {
413        0 // No fractional units
414    }
415
416    fn price_increment(&self) -> Price {
417        self.price_increment
418    }
419
420    fn size_increment(&self) -> Quantity {
421        Quantity::from(1)
422    }
423
424    fn multiplier(&self) -> Quantity {
425        self.multiplier
426    }
427
428    fn lot_size(&self) -> Option<Quantity> {
429        Some(self.lot_size)
430    }
431
432    fn max_quantity(&self) -> Option<Quantity> {
433        self.max_quantity
434    }
435
436    fn min_quantity(&self) -> Option<Quantity> {
437        self.min_quantity
438    }
439
440    fn max_notional(&self) -> Option<Money> {
441        None
442    }
443
444    fn min_notional(&self) -> Option<Money> {
445        None
446    }
447
448    fn max_price(&self) -> Option<Price> {
449        self.max_price
450    }
451
452    fn min_price(&self) -> Option<Price> {
453        self.min_price
454    }
455
456    fn ts_event(&self) -> UnixNanos {
457        self.ts_event
458    }
459
460    fn ts_init(&self) -> UnixNanos {
461        self.ts_init
462    }
463
464    fn margin_init(&self) -> Decimal {
465        self.margin_init
466    }
467
468    fn margin_maint(&self) -> Decimal {
469        self.margin_maint
470    }
471
472    fn maker_fee(&self) -> Decimal {
473        self.maker_fee
474    }
475
476    fn taker_fee(&self) -> Decimal {
477        self.taker_fee
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use rstest::rstest;
484    use rust_decimal_macros::dec;
485    use ustr::Ustr;
486
487    use crate::{
488        enums::{AssetClass, InstrumentClass},
489        identifiers::{InstrumentId, Symbol},
490        instruments::{Instrument, OptionSpread, stubs::*},
491        types::{Currency, Price, Quantity},
492    };
493
494    #[rstest]
495    fn test_trait_accessors(option_spread: OptionSpread) {
496        assert_eq!(
497            option_spread.id(),
498            InstrumentId::from("UD:U$: GN 2534559.GLBX")
499        );
500        assert_eq!(option_spread.asset_class(), AssetClass::FX);
501        assert_eq!(
502            option_spread.instrument_class(),
503            InstrumentClass::OptionSpread
504        );
505        assert_eq!(option_spread.quote_currency(), Currency::USD());
506        assert!(!option_spread.is_inverse());
507        assert_eq!(option_spread.exchange(), Some(Ustr::from("XCME")));
508        assert_eq!(option_spread.size_precision(), 0);
509        assert_eq!(option_spread.size_increment(), Quantity::from("1"));
510        assert_eq!(option_spread.min_quantity(), Some(Quantity::from("1")));
511    }
512
513    #[rstest]
514    fn test_new_checked_price_precision_mismatch() {
515        let result = OptionSpread::new_checked(
516            InstrumentId::from("TEST.GLBX"),
517            Symbol::from("TEST"),
518            AssetClass::FX,
519            Some(Ustr::from("XCME")),
520            Ustr::from("SR3"),
521            Ustr::from("GN"),
522            0.into(),
523            0.into(),
524            Currency::USD(),
525            4, // mismatch
526            Price::from("0.01"),
527            Quantity::from(1),
528            Quantity::from(1),
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            None,
536            None,
537            None,
538            None,
539            0.into(),
540            0.into(),
541        );
542        assert!(result.is_err());
543    }
544
545    #[rstest]
546    fn test_serialization_roundtrip(option_spread: OptionSpread) {
547        let json = serde_json::to_string(&option_spread).unwrap();
548        let deserialized: OptionSpread = serde_json::from_str(&json).unwrap();
549        assert_eq!(option_spread, deserialized);
550    }
551
552    #[rstest]
553    fn test_builder_matches_new_checked() {
554        let positional = OptionSpread::new_checked(
555            InstrumentId::from("UD:U$: GN 2534559.GLBX"),
556            Symbol::from("UD:U$: GN 2534559"),
557            AssetClass::FX,
558            Some(Ustr::from("XCME")),
559            Ustr::from("SR3"),
560            Ustr::from("GN"),
561            1.into(),
562            2.into(),
563            Currency::USD(),
564            2,
565            Price::from("0.01"),
566            Quantity::from(10),
567            Quantity::from(5),
568            Some(Quantity::from("100")),
569            Some(Quantity::from("1")),
570            Some(Price::from("999.0")),
571            Some(Price::from("1.0")),
572            Some(dec!(0.01)),
573            Some(dec!(0.02)),
574            Some(dec!(0.0002)),
575            Some(dec!(0.0004)),
576            None,
577            None,
578            3.into(),
579            4.into(),
580        )
581        .unwrap();
582
583        let built = OptionSpread::builder()
584            .instrument_id(InstrumentId::from("UD:U$: GN 2534559.GLBX"))
585            .raw_symbol(Symbol::from("UD:U$: GN 2534559"))
586            .asset_class(AssetClass::FX)
587            .exchange(Ustr::from("XCME"))
588            .underlying(Ustr::from("SR3"))
589            .strategy_type(Ustr::from("GN"))
590            .activation_ns(1.into())
591            .expiration_ns(2.into())
592            .currency(Currency::USD())
593            .price_precision(2)
594            .price_increment(Price::from("0.01"))
595            .multiplier(Quantity::from(10))
596            .lot_size(Quantity::from(5))
597            .max_quantity(Quantity::from("100"))
598            .min_quantity(Quantity::from("1"))
599            .max_price(Price::from("999.0"))
600            .min_price(Price::from("1.0"))
601            .margin_init(dec!(0.01))
602            .margin_maint(dec!(0.02))
603            .maker_fee(dec!(0.0002))
604            .taker_fee(dec!(0.0004))
605            .ts_event(3.into())
606            .ts_init(4.into())
607            .build()
608            .unwrap();
609
610        assert_eq!(
611            serde_json::to_value(&positional).unwrap(),
612            serde_json::to_value(&built).unwrap(),
613        );
614    }
615}