Skip to main content

nautilus_model/instruments/
crypto_futures_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 deliverable futures spread instrument, with crypto assets as underlying
41/// and for 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 CryptoFuturesSpread {
53    /// The instrument ID for the instrument.
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 contract 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 CryptoFuturesSpread {
115    /// Creates a new [`CryptoFuturesSpread`] 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_quantity,
202            min_quantity,
203            max_notional,
204            min_notional,
205            max_price,
206            min_price,
207            tick_scheme,
208            info,
209            ts_event,
210            ts_init,
211        })
212    }
213
214    /// Creates a new [`CryptoFuturesSpread`] 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 [`CryptoFuturesSpread`] instance.
287    ///
288    /// Required fields are enforced at compile time; optional fields can be omitted and default
289    /// the same way they do in [`CryptoFuturesSpread::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 [`CryptoFuturesSpread::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 CryptoFuturesSpread {
362    fn eq(&self, other: &Self) -> bool {
363        self.id == other.id
364    }
365}
366
367impl Eq for CryptoFuturesSpread {}
368
369impl Hash for CryptoFuturesSpread {
370    fn hash<H: Hasher>(&self, state: &mut H) {
371        self.id.hash(state);
372    }
373}
374
375impl Instrument for CryptoFuturesSpread {
376    fn tick_scheme(&self) -> Option<Ustr> {
377        self.tick_scheme
378    }
379    fn into_any(self) -> InstrumentAny {
380        InstrumentAny::CryptoFuturesSpread(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::FuturesSpread
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 isin(&self) -> Option<Ustr> {
416        None
417    }
418
419    fn exchange(&self) -> Option<Ustr> {
420        None
421    }
422
423    fn option_kind(&self) -> Option<OptionKind> {
424        None
425    }
426
427    fn is_inverse(&self) -> bool {
428        self.is_inverse
429    }
430
431    fn price_precision(&self) -> u8 {
432        self.price_precision
433    }
434
435    fn size_precision(&self) -> u8 {
436        self.size_precision
437    }
438
439    fn price_increment(&self) -> Price {
440        self.price_increment
441    }
442
443    fn size_increment(&self) -> Quantity {
444        self.size_increment
445    }
446
447    fn multiplier(&self) -> Quantity {
448        self.multiplier
449    }
450
451    fn lot_size(&self) -> Option<Quantity> {
452        Some(self.lot_size)
453    }
454
455    fn max_quantity(&self) -> Option<Quantity> {
456        self.max_quantity
457    }
458
459    fn min_quantity(&self) -> Option<Quantity> {
460        self.min_quantity
461    }
462
463    fn max_price(&self) -> Option<Price> {
464        self.max_price
465    }
466
467    fn min_price(&self) -> Option<Price> {
468        self.min_price
469    }
470
471    fn ts_event(&self) -> UnixNanos {
472        self.ts_event
473    }
474
475    fn ts_init(&self) -> UnixNanos {
476        self.ts_init
477    }
478
479    fn margin_init(&self) -> Decimal {
480        self.margin_init
481    }
482
483    fn margin_maint(&self) -> Decimal {
484        self.margin_maint
485    }
486
487    fn maker_fee(&self) -> Decimal {
488        self.maker_fee
489    }
490
491    fn taker_fee(&self) -> Decimal {
492        self.taker_fee
493    }
494
495    fn strike_price(&self) -> Option<Price> {
496        None
497    }
498
499    fn strategy_type(&self) -> Option<Ustr> {
500        Some(self.strategy_type)
501    }
502
503    fn activation_ns(&self) -> Option<UnixNanos> {
504        Some(self.activation_ns)
505    }
506
507    fn expiration_ns(&self) -> Option<UnixNanos> {
508        Some(self.expiration_ns)
509    }
510
511    fn max_notional(&self) -> Option<Money> {
512        self.max_notional
513    }
514
515    fn min_notional(&self) -> Option<Money> {
516        self.min_notional
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    use ustr::Ustr;
526
527    use crate::{
528        enums::{AssetClass, InstrumentClass},
529        identifiers::{InstrumentId, Symbol},
530        instruments::{CryptoFuturesSpread, Instrument, stubs::*},
531        types::{Currency, Money, Price, Quantity},
532    };
533
534    #[rstest]
535    fn test_trait_accessors(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
536        assert_eq!(
537            crypto_futures_spread_btc_deribit.id(),
538            InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT")
539        );
540        assert_eq!(
541            crypto_futures_spread_btc_deribit.asset_class(),
542            AssetClass::Cryptocurrency
543        );
544        assert_eq!(
545            crypto_futures_spread_btc_deribit.instrument_class(),
546            InstrumentClass::FuturesSpread
547        );
548        assert_eq!(
549            crypto_futures_spread_btc_deribit.quote_currency(),
550            Currency::USD()
551        );
552        assert_eq!(
553            crypto_futures_spread_btc_deribit.settlement_currency(),
554            Currency::BTC()
555        );
556        assert!(!crypto_futures_spread_btc_deribit.is_inverse());
557        assert_eq!(crypto_futures_spread_btc_deribit.price_precision(), 1);
558        assert_eq!(crypto_futures_spread_btc_deribit.size_precision(), 0);
559        assert_eq!(
560            crypto_futures_spread_btc_deribit.size_increment(),
561            Quantity::from("1")
562        );
563        assert!(crypto_futures_spread_btc_deribit.activation_ns().is_some());
564        assert!(crypto_futures_spread_btc_deribit.expiration_ns().is_some());
565    }
566
567    #[rstest]
568    fn test_new_checked_price_precision_mismatch() {
569        let result = CryptoFuturesSpread::new_checked(
570            InstrumentId::from("BTC-FS-TEST.DERIBIT"),
571            Symbol::from("BTC-FS-TEST"),
572            Currency::BTC(),
573            Currency::USD(),
574            Currency::BTC(),
575            false,
576            ustr::Ustr::from("FS"),
577            0.into(),
578            0.into(),
579            4, // mismatch
580            0,
581            Price::from("0.5"),
582            Quantity::from("1"),
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            None,
597            0.into(),
598            0.into(),
599        );
600        assert!(result.is_err());
601    }
602
603    #[rstest]
604    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
605    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
606    fn test_new_checked_rejects_non_positive_sizing(
607        #[case] multiplier: Option<Quantity>,
608        #[case] lot_size: Option<Quantity>,
609    ) {
610        let result = crypto_futures_spread_result(multiplier, lot_size);
611        assert!(result.is_err());
612    }
613
614    #[rstest]
615    fn test_serialization_roundtrip(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
616        let json = serde_json::to_string(&crypto_futures_spread_btc_deribit).unwrap();
617        let deserialized: CryptoFuturesSpread = serde_json::from_str(&json).unwrap();
618        assert_eq!(crypto_futures_spread_btc_deribit, deserialized);
619    }
620
621    #[rstest]
622    fn test_builder_matches_new_checked() {
623        let positional = CryptoFuturesSpread::new_checked(
624            InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"),
625            Symbol::from("BTC-FS-19MAY26_PERP"),
626            Currency::BTC(),
627            Currency::USD(),
628            Currency::USDC(),
629            false,
630            Ustr::from("FS"),
631            1.into(),
632            2.into(),
633            1,
634            0,
635            Price::from("0.5"),
636            Quantity::from("1"),
637            Some(Quantity::from("10")),
638            Some(Quantity::from("1")),
639            Some(Quantity::from("100")),
640            Some(Quantity::from("1")),
641            Some(Money::new(5_000_000.0, Currency::USD())),
642            Some(Money::new(10.0, Currency::USD())),
643            Some(Price::from("1000000.0")),
644            Some(Price::from("0.5")),
645            Some(dec!(0.01)),
646            Some(dec!(0.02)),
647            Some(dec!(0.0002)),
648            Some(dec!(0.0004)),
649            None,
650            None,
651            10.into(),
652            20.into(),
653        )
654        .unwrap();
655
656        let built = CryptoFuturesSpread::builder()
657            .instrument_id(InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"))
658            .raw_symbol(Symbol::from("BTC-FS-19MAY26_PERP"))
659            .underlying(Currency::BTC())
660            .quote_currency(Currency::USD())
661            .settlement_currency(Currency::USDC())
662            .is_inverse(false)
663            .strategy_type(Ustr::from("FS"))
664            .activation_ns(1.into())
665            .expiration_ns(2.into())
666            .price_precision(1)
667            .size_precision(0)
668            .price_increment(Price::from("0.5"))
669            .size_increment(Quantity::from("1"))
670            .multiplier(Quantity::from("10"))
671            .lot_size(Quantity::from("1"))
672            .max_quantity(Quantity::from("100"))
673            .min_quantity(Quantity::from("1"))
674            .max_notional(Money::new(5_000_000.0, Currency::USD()))
675            .min_notional(Money::new(10.0, Currency::USD()))
676            .max_price(Price::from("1000000.0"))
677            .min_price(Price::from("0.5"))
678            .margin_init(dec!(0.01))
679            .margin_maint(dec!(0.02))
680            .maker_fee(dec!(0.0002))
681            .taker_fee(dec!(0.0004))
682            .ts_event(10.into())
683            .ts_init(20.into())
684            .build()
685            .unwrap();
686
687        assert_eq!(
688            serde_json::to_value(&positional).unwrap(),
689            serde_json::to_value(&built).unwrap(),
690        );
691    }
692
693    fn crypto_futures_spread_result(
694        multiplier: Option<Quantity>,
695        lot_size: Option<Quantity>,
696    ) -> CorrectnessResult<CryptoFuturesSpread> {
697        CryptoFuturesSpread::new_checked(
698            InstrumentId::from("BTC-FS-TEST.DERIBIT"),
699            Symbol::from("BTC-FS-TEST"),
700            Currency::BTC(),
701            Currency::USD(),
702            Currency::BTC(),
703            false,
704            ustr::Ustr::from("FS"),
705            0.into(),
706            0.into(),
707            1,
708            0,
709            Price::from("0.5"),
710            Quantity::from("1"),
711            multiplier,
712            lot_size,
713            None,
714            None,
715            None,
716            None,
717            None,
718            None,
719            None,
720            None,
721            None,
722            None,
723            None,
724            None,
725            0.into(),
726            0.into(),
727        )
728    }
729}