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