Skip to main content

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