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