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