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