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::{CorrectnessResult, check_equal_u8, check_valid_string_ascii},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
27use crate::{
28    enums::{AssetClass, InstrumentClass, OptionKind},
29    identifiers::{InstrumentId, Symbol},
30    types::{
31        currency::Currency,
32        money::Money,
33        price::{Price, check_positive_price},
34        quantity::{Quantity, check_positive_quantity},
35    },
36};
37
38/// Represents a crypto deliverable futures spread instrument, with crypto assets as underlying
39/// and for settlement.
40#[repr(C)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
45)]
46#[cfg_attr(
47    feature = "python",
48    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct CryptoFuturesSpread {
51    /// The instrument ID for the instrument.
52    pub id: InstrumentId,
53    /// The raw/local/native symbol for the instrument, assigned by the venue.
54    pub raw_symbol: Symbol,
55    /// The underlying asset.
56    pub underlying: Currency,
57    /// The contract quote currency.
58    pub quote_currency: Currency,
59    /// The settlement currency.
60    pub settlement_currency: Currency,
61    /// If the instrument costing is inverse (quantity expressed in quote currency units).
62    pub is_inverse: bool,
63    /// The strategy type for 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 price decimal precision.
70    pub price_precision: u8,
71    /// The trading size decimal precision.
72    pub size_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 contract multiplier.
78    pub multiplier: Quantity,
79    /// The rounded lot unit size (standard/board).
80    pub lot_size: Quantity,
81    /// The initial (order) margin requirement in percentage of order value.
82    pub margin_init: Decimal,
83    /// The maintenance (position) margin in percentage of position value.
84    pub margin_maint: Decimal,
85    /// The fee rate for liquidity makers as a percentage of order value.
86    pub maker_fee: Decimal,
87    /// The fee rate for liquidity takers as a percentage of order value.
88    pub taker_fee: Decimal,
89    /// The maximum allowable order quantity.
90    pub max_quantity: Option<Quantity>,
91    /// The minimum allowable order quantity.
92    pub min_quantity: Option<Quantity>,
93    /// The maximum allowable order notional value.
94    pub max_notional: Option<Money>,
95    /// The minimum allowable order notional value.
96    pub min_notional: Option<Money>,
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 CryptoFuturesSpread {
113    #[expect(clippy::too_many_arguments)]
114    fn new_checked(
115        instrument_id: InstrumentId,
116        raw_symbol: Symbol,
117        underlying: Currency,
118        quote_currency: Currency,
119        settlement_currency: Currency,
120        is_inverse: bool,
121        strategy_type: Ustr,
122        activation_ns: UnixNanos,
123        expiration_ns: UnixNanos,
124        price_precision: u8,
125        size_precision: u8,
126        price_increment: Price,
127        size_increment: Quantity,
128        multiplier: Option<Quantity>,
129        lot_size: Option<Quantity>,
130        max_quantity: Option<Quantity>,
131        min_quantity: Option<Quantity>,
132        max_notional: Option<Money>,
133        min_notional: Option<Money>,
134        max_price: Option<Price>,
135        min_price: Option<Price>,
136        margin_init: Option<Decimal>,
137        margin_maint: Option<Decimal>,
138        maker_fee: Option<Decimal>,
139        taker_fee: Option<Decimal>,
140        tick_scheme: Option<Ustr>,
141        info: Option<Params>,
142        ts_event: UnixNanos,
143        ts_init: UnixNanos,
144    ) -> CorrectnessResult<Self> {
145        check_valid_string_ascii(strategy_type, stringify!(strategy_type))?;
146        check_equal_u8(
147            price_precision,
148            price_increment.precision,
149            stringify!(price_precision),
150            stringify!(price_increment.precision),
151        )?;
152        check_equal_u8(
153            size_precision,
154            size_increment.precision,
155            stringify!(size_precision),
156            stringify!(size_increment.precision),
157        )?;
158        check_positive_price(price_increment, stringify!(price_increment))?;
159        check_positive_quantity(size_increment, stringify!(size_increment))?;
160        check_tick_scheme(tick_scheme)?;
161
162        if let Some(multiplier) = multiplier {
163            check_positive_quantity(multiplier, stringify!(multiplier))?;
164        }
165
166        if let Some(lot_size) = lot_size {
167            check_positive_quantity(lot_size, stringify!(lot_size))?;
168        }
169
170        Ok(Self {
171            id: instrument_id,
172            raw_symbol,
173            underlying,
174            quote_currency,
175            settlement_currency,
176            is_inverse,
177            strategy_type,
178            activation_ns,
179            expiration_ns,
180            price_precision,
181            size_precision,
182            price_increment,
183            size_increment,
184            multiplier: multiplier.unwrap_or(Quantity::from(1)),
185            lot_size: lot_size.unwrap_or(Quantity::from(1)),
186            margin_init: margin_init.unwrap_or_default(),
187            margin_maint: margin_maint.unwrap_or_default(),
188            maker_fee: maker_fee.unwrap_or_default(),
189            taker_fee: taker_fee.unwrap_or_default(),
190            max_quantity,
191            min_quantity,
192            max_notional,
193            min_notional,
194            max_price,
195            min_price,
196            tick_scheme,
197            info,
198            ts_event,
199            ts_init,
200        })
201    }
202
203    /// Returns a fluent builder for a [`CryptoFuturesSpread`] instance.
204    ///
205    /// Required fields are enforced at compile time; optional fields can be omitted and use the
206    /// same defaults as checked construction. The same correctness checks run on `build`.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if any input validation fails.
211    #[builder(start_fn = builder, finish_fn = build)]
212    pub fn build_checked(
213        instrument_id: InstrumentId,
214        raw_symbol: Symbol,
215        underlying: Currency,
216        quote_currency: Currency,
217        settlement_currency: Currency,
218        is_inverse: bool,
219        strategy_type: Ustr,
220        activation_ns: UnixNanos,
221        expiration_ns: UnixNanos,
222        price_precision: u8,
223        size_precision: u8,
224        price_increment: Price,
225        size_increment: Quantity,
226        multiplier: Option<Quantity>,
227        lot_size: Option<Quantity>,
228        max_quantity: Option<Quantity>,
229        min_quantity: Option<Quantity>,
230        max_notional: Option<Money>,
231        min_notional: Option<Money>,
232        max_price: Option<Price>,
233        min_price: Option<Price>,
234        margin_init: Option<Decimal>,
235        margin_maint: Option<Decimal>,
236        maker_fee: Option<Decimal>,
237        taker_fee: Option<Decimal>,
238        tick_scheme: Option<Ustr>,
239        info: Option<Params>,
240        ts_event: UnixNanos,
241        ts_init: UnixNanos,
242    ) -> CorrectnessResult<Self> {
243        Self::new_checked(
244            instrument_id,
245            raw_symbol,
246            underlying,
247            quote_currency,
248            settlement_currency,
249            is_inverse,
250            strategy_type,
251            activation_ns,
252            expiration_ns,
253            price_precision,
254            size_precision,
255            price_increment,
256            size_increment,
257            multiplier,
258            lot_size,
259            max_quantity,
260            min_quantity,
261            max_notional,
262            min_notional,
263            max_price,
264            min_price,
265            margin_init,
266            margin_maint,
267            maker_fee,
268            taker_fee,
269            tick_scheme,
270            info,
271            ts_event,
272            ts_init,
273        )
274    }
275}
276
277impl PartialEq<Self> for CryptoFuturesSpread {
278    fn eq(&self, other: &Self) -> bool {
279        self.id == other.id
280    }
281}
282
283impl Eq for CryptoFuturesSpread {}
284
285impl Hash for CryptoFuturesSpread {
286    fn hash<H: Hasher>(&self, state: &mut H) {
287        self.id.hash(state);
288    }
289}
290
291impl Instrument for CryptoFuturesSpread {
292    fn into_any(self) -> InstrumentAny {
293        InstrumentAny::CryptoFuturesSpread(self)
294    }
295
296    fn id(&self) -> InstrumentId {
297        self.id
298    }
299
300    fn raw_symbol(&self) -> Symbol {
301        self.raw_symbol
302    }
303
304    fn asset_class(&self) -> AssetClass {
305        AssetClass::Cryptocurrency
306    }
307
308    fn instrument_class(&self) -> InstrumentClass {
309        InstrumentClass::FuturesSpread
310    }
311
312    fn underlying(&self) -> Option<Ustr> {
313        Some(self.underlying.code)
314    }
315
316    fn base_currency(&self) -> Option<Currency> {
317        Some(self.underlying)
318    }
319
320    fn quote_currency(&self) -> Currency {
321        self.quote_currency
322    }
323
324    fn settlement_currency(&self) -> Currency {
325        self.settlement_currency
326    }
327
328    fn isin(&self) -> Option<Ustr> {
329        None
330    }
331
332    fn exchange(&self) -> Option<Ustr> {
333        None
334    }
335
336    fn option_kind(&self) -> Option<OptionKind> {
337        None
338    }
339
340    fn is_inverse(&self) -> bool {
341        self.is_inverse
342    }
343
344    fn price_precision(&self) -> u8 {
345        self.price_precision
346    }
347
348    fn size_precision(&self) -> u8 {
349        self.size_precision
350    }
351
352    fn price_increment(&self) -> Price {
353        self.price_increment
354    }
355
356    fn size_increment(&self) -> Quantity {
357        self.size_increment
358    }
359
360    fn multiplier(&self) -> Quantity {
361        self.multiplier
362    }
363
364    fn lot_size(&self) -> Option<Quantity> {
365        Some(self.lot_size)
366    }
367
368    fn max_quantity(&self) -> Option<Quantity> {
369        self.max_quantity
370    }
371
372    fn min_quantity(&self) -> Option<Quantity> {
373        self.min_quantity
374    }
375
376    fn max_price(&self) -> Option<Price> {
377        self.max_price
378    }
379
380    fn min_price(&self) -> Option<Price> {
381        self.min_price
382    }
383
384    fn tick_scheme(&self) -> Option<Ustr> {
385        self.tick_scheme
386    }
387
388    fn info(&self) -> Option<&Params> {
389        self.info.as_ref()
390    }
391
392    fn ts_event(&self) -> UnixNanos {
393        self.ts_event
394    }
395
396    fn ts_init(&self) -> UnixNanos {
397        self.ts_init
398    }
399
400    fn margin_init(&self) -> Decimal {
401        self.margin_init
402    }
403
404    fn margin_maint(&self) -> Decimal {
405        self.margin_maint
406    }
407
408    fn maker_fee(&self) -> Decimal {
409        self.maker_fee
410    }
411
412    fn taker_fee(&self) -> Decimal {
413        self.taker_fee
414    }
415
416    fn strike_price(&self) -> Option<Price> {
417        None
418    }
419
420    fn strategy_type(&self) -> Option<Ustr> {
421        Some(self.strategy_type)
422    }
423
424    fn activation_ns(&self) -> Option<UnixNanos> {
425        Some(self.activation_ns)
426    }
427
428    fn expiration_ns(&self) -> Option<UnixNanos> {
429        Some(self.expiration_ns)
430    }
431
432    fn max_notional(&self) -> Option<Money> {
433        self.max_notional
434    }
435
436    fn min_notional(&self) -> Option<Money> {
437        self.min_notional
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use nautilus_core::correctness::CorrectnessResult;
444    use rstest::rstest;
445    use rust_decimal_macros::dec;
446    use ustr::Ustr;
447
448    use crate::{
449        enums::{AssetClass, InstrumentClass},
450        identifiers::{InstrumentId, Symbol},
451        instruments::{CryptoFuturesSpread, Instrument, stubs::*},
452        types::{Currency, Money, Price, Quantity},
453    };
454
455    #[rstest]
456    fn test_trait_accessors(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
457        assert_eq!(
458            crypto_futures_spread_btc_deribit.id(),
459            InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT")
460        );
461        assert_eq!(
462            crypto_futures_spread_btc_deribit.asset_class(),
463            AssetClass::Cryptocurrency
464        );
465        assert_eq!(
466            crypto_futures_spread_btc_deribit.instrument_class(),
467            InstrumentClass::FuturesSpread
468        );
469        assert_eq!(
470            crypto_futures_spread_btc_deribit.quote_currency(),
471            Currency::USD()
472        );
473        assert_eq!(
474            crypto_futures_spread_btc_deribit.settlement_currency(),
475            Currency::BTC()
476        );
477        assert!(!crypto_futures_spread_btc_deribit.is_inverse());
478        assert_eq!(crypto_futures_spread_btc_deribit.price_precision(), 1);
479        assert_eq!(crypto_futures_spread_btc_deribit.size_precision(), 0);
480        assert_eq!(
481            crypto_futures_spread_btc_deribit.size_increment(),
482            Quantity::from("1")
483        );
484        assert!(crypto_futures_spread_btc_deribit.activation_ns().is_some());
485        assert!(crypto_futures_spread_btc_deribit.expiration_ns().is_some());
486    }
487
488    #[rstest]
489    fn test_new_checked_price_precision_mismatch() {
490        let result = CryptoFuturesSpread::new_checked(
491            InstrumentId::from("BTC-FS-TEST.DERIBIT"),
492            Symbol::from("BTC-FS-TEST"),
493            Currency::BTC(),
494            Currency::USD(),
495            Currency::BTC(),
496            false,
497            ustr::Ustr::from("FS"),
498            0.into(),
499            0.into(),
500            4, // mismatch
501            0,
502            Price::from("0.5"),
503            Quantity::from("1"),
504            None,
505            None,
506            None,
507            None,
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::zero_multiplier(Some(Quantity::from("0")), None)]
526    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
527    fn test_new_checked_rejects_non_positive_sizing(
528        #[case] multiplier: Option<Quantity>,
529        #[case] lot_size: Option<Quantity>,
530    ) {
531        let result = crypto_futures_spread_result(multiplier, lot_size);
532        assert!(result.is_err());
533    }
534
535    #[rstest]
536    fn test_serialization_roundtrip(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
537        let json = serde_json::to_string(&crypto_futures_spread_btc_deribit).unwrap();
538        let deserialized: CryptoFuturesSpread = serde_json::from_str(&json).unwrap();
539        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
540    }
541
542    #[rstest]
543    fn test_builder_matches_new_checked() {
544        let positional = CryptoFuturesSpread::new_checked(
545            InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"),
546            Symbol::from("BTC-FS-19MAY26_PERP"),
547            Currency::BTC(),
548            Currency::USD(),
549            Currency::USDC(),
550            false,
551            Ustr::from("FS"),
552            1.into(),
553            2.into(),
554            1,
555            0,
556            Price::from("0.5"),
557            Quantity::from("1"),
558            Some(Quantity::from("10")),
559            Some(Quantity::from("1")),
560            Some(Quantity::from("100")),
561            Some(Quantity::from("1")),
562            Some(Money::new(5_000_000.0, Currency::USD())),
563            Some(Money::new(10.0, Currency::USD())),
564            Some(Price::from("1000000.0")),
565            Some(Price::from("0.5")),
566            Some(dec!(0.01)),
567            Some(dec!(0.02)),
568            Some(dec!(0.0002)),
569            Some(dec!(0.0004)),
570            None,
571            None,
572            10.into(),
573            20.into(),
574        )
575        .unwrap();
576
577        let built = CryptoFuturesSpread::builder()
578            .instrument_id(InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"))
579            .raw_symbol(Symbol::from("BTC-FS-19MAY26_PERP"))
580            .underlying(Currency::BTC())
581            .quote_currency(Currency::USD())
582            .settlement_currency(Currency::USDC())
583            .is_inverse(false)
584            .strategy_type(Ustr::from("FS"))
585            .activation_ns(1.into())
586            .expiration_ns(2.into())
587            .price_precision(1)
588            .size_precision(0)
589            .price_increment(Price::from("0.5"))
590            .size_increment(Quantity::from("1"))
591            .multiplier(Quantity::from("10"))
592            .lot_size(Quantity::from("1"))
593            .max_quantity(Quantity::from("100"))
594            .min_quantity(Quantity::from("1"))
595            .max_notional(Money::new(5_000_000.0, Currency::USD()))
596            .min_notional(Money::new(10.0, Currency::USD()))
597            .max_price(Price::from("1000000.0"))
598            .min_price(Price::from("0.5"))
599            .margin_init(dec!(0.01))
600            .margin_maint(dec!(0.02))
601            .maker_fee(dec!(0.0002))
602            .taker_fee(dec!(0.0004))
603            .ts_event(10.into())
604            .ts_init(20.into())
605            .build()
606            .unwrap();
607
608        assert_eq!(
609            serde_json::to_value(&positional).unwrap(),
610            serde_json::to_value(&built).unwrap(),
611        );
612    }
613
614    fn crypto_futures_spread_result(
615        multiplier: Option<Quantity>,
616        lot_size: Option<Quantity>,
617    ) -> CorrectnessResult<CryptoFuturesSpread> {
618        CryptoFuturesSpread::new_checked(
619            InstrumentId::from("BTC-FS-TEST.DERIBIT"),
620            Symbol::from("BTC-FS-TEST"),
621            Currency::BTC(),
622            Currency::USD(),
623            Currency::BTC(),
624            false,
625            ustr::Ustr::from("FS"),
626            0.into(),
627            0.into(),
628            1,
629            0,
630            Price::from("0.5"),
631            Quantity::from("1"),
632            multiplier,
633            lot_size,
634            None,
635            None,
636            None,
637            None,
638            None,
639            None,
640            None,
641            None,
642            None,
643            None,
644            None,
645            None,
646            0.into(),
647            0.into(),
648        )
649    }
650}