Skip to main content

nautilus_model/instruments/
crypto_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    },
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use ustr::Ustr;
27
28use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
29use crate::{
30    enums::{AssetClass, InstrumentClass, OptionKind},
31    identifiers::{InstrumentId, Symbol},
32    types::{
33        currency::Currency,
34        money::Money,
35        price::{Price, check_positive_price},
36        quantity::{Quantity, check_positive_quantity},
37    },
38};
39
40/// Represents a crypto option spread instrument, with crypto assets as underlying and for
41/// settlement.
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 CryptoOptionSpread {
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 underlying asset.
58    pub underlying: Currency,
59    /// The contract quote currency.
60    pub quote_currency: Currency,
61    /// The settlement currency.
62    pub settlement_currency: Currency,
63    /// If the instrument costing is inverse (quantity expressed in quote currency units).
64    pub is_inverse: bool,
65    /// The strategy type for the spread.
66    pub strategy_type: Ustr,
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 price decimal precision.
72    pub price_precision: u8,
73    /// The trading size decimal precision.
74    pub size_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 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 order notional value.
96    pub max_notional: Option<Money>,
97    /// The minimum allowable order notional value.
98    pub min_notional: Option<Money>,
99    /// The maximum allowable quoted price.
100    pub max_price: Option<Price>,
101    /// The minimum allowable quoted price.
102    pub min_price: Option<Price>,
103    /// The registered variable tick scheme name.
104    pub tick_scheme: Option<Ustr>,
105    /// Additional instrument metadata as a JSON-serializable dictionary.
106    pub info: Option<Params>,
107    /// UNIX timestamp (nanoseconds) when the data event occurred.
108    pub ts_event: UnixNanos,
109    /// UNIX timestamp (nanoseconds) when the data object was initialized.
110    pub ts_init: UnixNanos,
111}
112
113#[bon::bon]
114impl CryptoOptionSpread {
115    /// Creates a new [`CryptoOptionSpread`] instance with correctness checking.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if any input validation fails.
120    ///
121    /// # Notes
122    ///
123    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
124    #[expect(clippy::too_many_arguments)]
125    pub fn new_checked(
126        instrument_id: InstrumentId,
127        raw_symbol: Symbol,
128        underlying: Currency,
129        quote_currency: Currency,
130        settlement_currency: Currency,
131        is_inverse: bool,
132        strategy_type: Ustr,
133        activation_ns: UnixNanos,
134        expiration_ns: UnixNanos,
135        price_precision: u8,
136        size_precision: u8,
137        price_increment: Price,
138        size_increment: Quantity,
139        multiplier: Option<Quantity>,
140        lot_size: Option<Quantity>,
141        max_quantity: Option<Quantity>,
142        min_quantity: Option<Quantity>,
143        max_notional: Option<Money>,
144        min_notional: Option<Money>,
145        max_price: Option<Price>,
146        min_price: Option<Price>,
147        margin_init: Option<Decimal>,
148        margin_maint: Option<Decimal>,
149        maker_fee: Option<Decimal>,
150        taker_fee: Option<Decimal>,
151        tick_scheme: Option<Ustr>,
152        info: Option<Params>,
153        ts_event: UnixNanos,
154        ts_init: UnixNanos,
155    ) -> CorrectnessResult<Self> {
156        check_valid_string_ascii(strategy_type.as_str(), stringify!(strategy_type))?;
157        check_equal_u8(
158            price_precision,
159            price_increment.precision,
160            stringify!(price_precision),
161            stringify!(price_increment.precision),
162        )?;
163        check_equal_u8(
164            size_precision,
165            size_increment.precision,
166            stringify!(size_precision),
167            stringify!(size_increment.precision),
168        )?;
169        check_positive_price(price_increment, stringify!(price_increment))?;
170        check_positive_quantity(size_increment, stringify!(size_increment))?;
171        check_tick_scheme(tick_scheme)?;
172
173        if let Some(multiplier) = multiplier {
174            check_positive_quantity(multiplier, stringify!(multiplier))?;
175        }
176
177        if let Some(lot_size) = lot_size {
178            check_positive_quantity(lot_size, stringify!(lot_size))?;
179        }
180
181        Ok(Self {
182            id: instrument_id,
183            raw_symbol,
184            underlying,
185            quote_currency,
186            settlement_currency,
187            is_inverse,
188            strategy_type,
189            activation_ns,
190            expiration_ns,
191            price_precision,
192            size_precision,
193            price_increment,
194            size_increment,
195            multiplier: multiplier.unwrap_or(Quantity::from(1)),
196            lot_size: lot_size.unwrap_or(Quantity::from(1)),
197            margin_init: margin_init.unwrap_or_default(),
198            margin_maint: margin_maint.unwrap_or_default(),
199            maker_fee: maker_fee.unwrap_or_default(),
200            taker_fee: taker_fee.unwrap_or_default(),
201            max_notional,
202            min_notional,
203            max_quantity,
204            min_quantity,
205            max_price,
206            min_price,
207            tick_scheme,
208            info,
209            ts_event,
210            ts_init,
211        })
212    }
213
214    /// Creates a new [`CryptoOptionSpread`] instance.
215    ///
216    /// # Panics
217    ///
218    /// Panics if any parameter is invalid (see `new_checked`).
219    #[expect(clippy::too_many_arguments)]
220    #[must_use]
221    pub fn new(
222        instrument_id: InstrumentId,
223        raw_symbol: Symbol,
224        underlying: Currency,
225        quote_currency: Currency,
226        settlement_currency: Currency,
227        is_inverse: bool,
228        strategy_type: Ustr,
229        activation_ns: UnixNanos,
230        expiration_ns: UnixNanos,
231        price_precision: u8,
232        size_precision: u8,
233        price_increment: Price,
234        size_increment: Quantity,
235        multiplier: Option<Quantity>,
236        lot_size: Option<Quantity>,
237        max_quantity: Option<Quantity>,
238        min_quantity: Option<Quantity>,
239        max_notional: Option<Money>,
240        min_notional: Option<Money>,
241        max_price: Option<Price>,
242        min_price: Option<Price>,
243        margin_init: Option<Decimal>,
244        margin_maint: Option<Decimal>,
245        maker_fee: Option<Decimal>,
246        taker_fee: Option<Decimal>,
247        tick_scheme: Option<Ustr>,
248        info: Option<Params>,
249        ts_event: UnixNanos,
250        ts_init: UnixNanos,
251    ) -> Self {
252        Self::new_checked(
253            instrument_id,
254            raw_symbol,
255            underlying,
256            quote_currency,
257            settlement_currency,
258            is_inverse,
259            strategy_type,
260            activation_ns,
261            expiration_ns,
262            price_precision,
263            size_precision,
264            price_increment,
265            size_increment,
266            multiplier,
267            lot_size,
268            max_quantity,
269            min_quantity,
270            max_notional,
271            min_notional,
272            max_price,
273            min_price,
274            margin_init,
275            margin_maint,
276            maker_fee,
277            taker_fee,
278            tick_scheme,
279            info,
280            ts_event,
281            ts_init,
282        )
283        .expect_display(FAILED)
284    }
285
286    /// Returns a fluent builder for a [`CryptoOptionSpread`] instance.
287    ///
288    /// Required fields are enforced at compile time; optional fields can be omitted and default
289    /// the same way they do in [`CryptoOptionSpread::new_checked`], which the builder calls so the
290    /// same correctness checks run on `build`.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if any input validation fails (see [`CryptoOptionSpread::new_checked`]).
295    #[builder(start_fn = builder, finish_fn = build)]
296    pub fn build_checked(
297        instrument_id: InstrumentId,
298        raw_symbol: Symbol,
299        underlying: Currency,
300        quote_currency: Currency,
301        settlement_currency: Currency,
302        is_inverse: bool,
303        strategy_type: Ustr,
304        activation_ns: UnixNanos,
305        expiration_ns: UnixNanos,
306        price_precision: u8,
307        size_precision: u8,
308        price_increment: Price,
309        size_increment: Quantity,
310        multiplier: Option<Quantity>,
311        lot_size: Option<Quantity>,
312        max_quantity: Option<Quantity>,
313        min_quantity: Option<Quantity>,
314        max_notional: Option<Money>,
315        min_notional: Option<Money>,
316        max_price: Option<Price>,
317        min_price: Option<Price>,
318        margin_init: Option<Decimal>,
319        margin_maint: Option<Decimal>,
320        maker_fee: Option<Decimal>,
321        taker_fee: Option<Decimal>,
322        tick_scheme: Option<Ustr>,
323        info: Option<Params>,
324        ts_event: UnixNanos,
325        ts_init: UnixNanos,
326    ) -> CorrectnessResult<Self> {
327        Self::new_checked(
328            instrument_id,
329            raw_symbol,
330            underlying,
331            quote_currency,
332            settlement_currency,
333            is_inverse,
334            strategy_type,
335            activation_ns,
336            expiration_ns,
337            price_precision,
338            size_precision,
339            price_increment,
340            size_increment,
341            multiplier,
342            lot_size,
343            max_quantity,
344            min_quantity,
345            max_notional,
346            min_notional,
347            max_price,
348            min_price,
349            margin_init,
350            margin_maint,
351            maker_fee,
352            taker_fee,
353            tick_scheme,
354            info,
355            ts_event,
356            ts_init,
357        )
358    }
359}
360
361impl PartialEq<Self> for CryptoOptionSpread {
362    fn eq(&self, other: &Self) -> bool {
363        self.id == other.id
364    }
365}
366
367impl Eq for CryptoOptionSpread {}
368
369impl Hash for CryptoOptionSpread {
370    fn hash<H: Hasher>(&self, state: &mut H) {
371        self.id.hash(state);
372    }
373}
374
375impl Instrument for CryptoOptionSpread {
376    fn tick_scheme(&self) -> Option<Ustr> {
377        self.tick_scheme
378    }
379    fn into_any(self) -> InstrumentAny {
380        InstrumentAny::CryptoOptionSpread(self)
381    }
382
383    fn id(&self) -> InstrumentId {
384        self.id
385    }
386
387    fn raw_symbol(&self) -> Symbol {
388        self.raw_symbol
389    }
390
391    fn asset_class(&self) -> AssetClass {
392        AssetClass::Cryptocurrency
393    }
394
395    fn instrument_class(&self) -> InstrumentClass {
396        InstrumentClass::OptionSpread
397    }
398
399    fn underlying(&self) -> Option<Ustr> {
400        Some(self.underlying.code)
401    }
402
403    fn base_currency(&self) -> Option<Currency> {
404        Some(self.underlying)
405    }
406
407    fn quote_currency(&self) -> Currency {
408        self.quote_currency
409    }
410
411    fn settlement_currency(&self) -> Currency {
412        self.settlement_currency
413    }
414
415    fn is_inverse(&self) -> bool {
416        self.is_inverse
417    }
418
419    fn isin(&self) -> Option<Ustr> {
420        None
421    }
422
423    fn option_kind(&self) -> Option<OptionKind> {
424        None
425    }
426
427    fn strike_price(&self) -> Option<Price> {
428        None
429    }
430
431    fn strategy_type(&self) -> Option<Ustr> {
432        Some(self.strategy_type)
433    }
434
435    fn activation_ns(&self) -> Option<UnixNanos> {
436        Some(self.activation_ns)
437    }
438
439    fn expiration_ns(&self) -> Option<UnixNanos> {
440        Some(self.expiration_ns)
441    }
442
443    fn exchange(&self) -> Option<Ustr> {
444        None
445    }
446
447    fn price_precision(&self) -> u8 {
448        self.price_precision
449    }
450
451    fn size_precision(&self) -> u8 {
452        self.size_precision
453    }
454
455    fn price_increment(&self) -> Price {
456        self.price_increment
457    }
458
459    fn size_increment(&self) -> Quantity {
460        self.size_increment
461    }
462
463    fn multiplier(&self) -> Quantity {
464        self.multiplier
465    }
466
467    fn lot_size(&self) -> Option<Quantity> {
468        Some(self.lot_size)
469    }
470
471    fn max_quantity(&self) -> Option<Quantity> {
472        self.max_quantity
473    }
474
475    fn min_quantity(&self) -> Option<Quantity> {
476        self.min_quantity
477    }
478
479    fn max_notional(&self) -> Option<Money> {
480        self.max_notional
481    }
482
483    fn min_notional(&self) -> Option<Money> {
484        self.min_notional
485    }
486
487    fn max_price(&self) -> Option<Price> {
488        self.max_price
489    }
490
491    fn min_price(&self) -> Option<Price> {
492        self.min_price
493    }
494
495    fn ts_event(&self) -> UnixNanos {
496        self.ts_event
497    }
498
499    fn ts_init(&self) -> UnixNanos {
500        self.ts_init
501    }
502
503    fn margin_init(&self) -> Decimal {
504        self.margin_init
505    }
506
507    fn margin_maint(&self) -> Decimal {
508        self.margin_maint
509    }
510
511    fn maker_fee(&self) -> Decimal {
512        self.maker_fee
513    }
514
515    fn taker_fee(&self) -> Decimal {
516        self.taker_fee
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use nautilus_core::correctness::CorrectnessResult;
523    use rstest::rstest;
524    use rust_decimal_macros::dec;
525
526    use crate::{
527        enums::{AssetClass, InstrumentClass},
528        identifiers::{InstrumentId, Symbol},
529        instruments::{CryptoOptionSpread, Instrument, stubs::*},
530        types::{Currency, Money, Price, Quantity},
531    };
532
533    #[rstest]
534    fn test_trait_accessors(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
535        assert_eq!(
536            crypto_option_spread_btc_deribit.id(),
537            InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT")
538        );
539        assert_eq!(
540            crypto_option_spread_btc_deribit.asset_class(),
541            AssetClass::Cryptocurrency
542        );
543        assert_eq!(
544            crypto_option_spread_btc_deribit.instrument_class(),
545            InstrumentClass::OptionSpread
546        );
547        assert_eq!(
548            crypto_option_spread_btc_deribit.quote_currency(),
549            Currency::USD()
550        );
551        assert_eq!(
552            crypto_option_spread_btc_deribit.settlement_currency(),
553            Currency::BTC()
554        );
555        assert!(!crypto_option_spread_btc_deribit.is_inverse());
556        assert_eq!(crypto_option_spread_btc_deribit.price_precision(), 4);
557        assert_eq!(crypto_option_spread_btc_deribit.size_precision(), 1);
558        assert_eq!(
559            crypto_option_spread_btc_deribit.size_increment(),
560            Quantity::from("0.1")
561        );
562        assert!(crypto_option_spread_btc_deribit.activation_ns().is_some());
563        assert!(crypto_option_spread_btc_deribit.expiration_ns().is_some());
564    }
565
566    #[rstest]
567    fn test_new_checked_price_precision_mismatch() {
568        let result = CryptoOptionSpread::new_checked(
569            InstrumentId::from("BTC-CS-TEST.DERIBIT"),
570            Symbol::from("BTC-CS-TEST"),
571            Currency::BTC(),
572            Currency::USD(),
573            Currency::BTC(),
574            false,
575            ustr::Ustr::from("CS"),
576            0.into(),
577            0.into(),
578            4, // mismatch
579            1,
580            Price::from("0.001"),
581            Quantity::from("0.1"),
582            None,
583            None,
584            None,
585            None,
586            None,
587            None,
588            None,
589            None,
590            None,
591            None,
592            None,
593            None,
594            None,
595            None,
596            0.into(),
597            0.into(),
598        );
599        assert!(result.is_err());
600    }
601
602    #[rstest]
603    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
604    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
605    fn test_new_checked_rejects_non_positive_sizing(
606        #[case] multiplier: Option<Quantity>,
607        #[case] lot_size: Option<Quantity>,
608    ) {
609        let result = crypto_option_spread_result(multiplier, lot_size);
610        assert!(result.is_err());
611    }
612
613    #[rstest]
614    fn test_serialization_roundtrip(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
615        let json = serde_json::to_string(&crypto_option_spread_btc_deribit).unwrap();
616        let deserialized: CryptoOptionSpread = serde_json::from_str(&json).unwrap();
617        assert_eq!(crypto_option_spread_btc_deribit, deserialized);
618    }
619
620    #[rstest]
621    fn test_builder_matches_new_checked() {
622        let positional = CryptoOptionSpread::new_checked(
623            InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"),
624            Symbol::from("BTC-CS-19MAY26-70000_75000"),
625            Currency::BTC(),
626            Currency::USDC(),
627            Currency::USDT(),
628            false,
629            ustr::Ustr::from("CS"),
630            1.into(),
631            2.into(),
632            4,
633            1,
634            Price::from("0.0001"),
635            Quantity::from("0.1"),
636            Some(Quantity::from("10")),
637            Some(Quantity::from("5")),
638            Some(Quantity::from("1000.0")),
639            Some(Quantity::from("0.1")),
640            Some(Money::new(1_000_000.0, Currency::USDC())),
641            Some(Money::new(10.0, Currency::USDC())),
642            Some(Price::from("9.9999")),
643            Some(Price::from("0.0001")),
644            Some(dec!(0.01)),
645            Some(dec!(0.02)),
646            Some(dec!(0.0002)),
647            Some(dec!(0.0004)),
648            None,
649            None,
650            1.into(),
651            2.into(),
652        )
653        .unwrap();
654
655        let built = CryptoOptionSpread::builder()
656            .instrument_id(InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"))
657            .raw_symbol(Symbol::from("BTC-CS-19MAY26-70000_75000"))
658            .underlying(Currency::BTC())
659            .quote_currency(Currency::USDC())
660            .settlement_currency(Currency::USDT())
661            .is_inverse(false)
662            .strategy_type(ustr::Ustr::from("CS"))
663            .activation_ns(1.into())
664            .expiration_ns(2.into())
665            .price_precision(4)
666            .size_precision(1)
667            .price_increment(Price::from("0.0001"))
668            .size_increment(Quantity::from("0.1"))
669            .multiplier(Quantity::from("10"))
670            .lot_size(Quantity::from("5"))
671            .max_quantity(Quantity::from("1000.0"))
672            .min_quantity(Quantity::from("0.1"))
673            .max_notional(Money::new(1_000_000.0, Currency::USDC()))
674            .min_notional(Money::new(10.0, Currency::USDC()))
675            .max_price(Price::from("9.9999"))
676            .min_price(Price::from("0.0001"))
677            .margin_init(dec!(0.01))
678            .margin_maint(dec!(0.02))
679            .maker_fee(dec!(0.0002))
680            .taker_fee(dec!(0.0004))
681            .ts_event(1.into())
682            .ts_init(2.into())
683            .build()
684            .unwrap();
685
686        assert_eq!(
687            serde_json::to_value(&positional).unwrap(),
688            serde_json::to_value(&built).unwrap(),
689        );
690    }
691
692    fn crypto_option_spread_result(
693        multiplier: Option<Quantity>,
694        lot_size: Option<Quantity>,
695    ) -> CorrectnessResult<CryptoOptionSpread> {
696        CryptoOptionSpread::new_checked(
697            InstrumentId::from("BTC-CS-TEST.DERIBIT"),
698            Symbol::from("BTC-CS-TEST"),
699            Currency::BTC(),
700            Currency::USD(),
701            Currency::BTC(),
702            false,
703            ustr::Ustr::from("CS"),
704            0.into(),
705            0.into(),
706            4,
707            1,
708            Price::from("0.0001"),
709            Quantity::from("0.1"),
710            multiplier,
711            lot_size,
712            None,
713            None,
714            None,
715            None,
716            None,
717            None,
718            None,
719            None,
720            None,
721            None,
722            None,
723            None,
724            0.into(),
725            0.into(),
726        )
727    }
728}