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 into_any(self) -> InstrumentAny {
285        InstrumentAny::CryptoFuture(self)
286    }
287
288    fn id(&self) -> InstrumentId {
289        self.id
290    }
291
292    fn raw_symbol(&self) -> Symbol {
293        self.raw_symbol
294    }
295
296    fn asset_class(&self) -> AssetClass {
297        AssetClass::Cryptocurrency
298    }
299
300    fn instrument_class(&self) -> InstrumentClass {
301        InstrumentClass::Future
302    }
303
304    fn underlying(&self) -> Option<Ustr> {
305        Some(self.underlying.code)
306    }
307
308    fn base_currency(&self) -> Option<Currency> {
309        Some(self.underlying)
310    }
311
312    fn quote_currency(&self) -> Currency {
313        self.quote_currency
314    }
315
316    fn settlement_currency(&self) -> Currency {
317        self.settlement_currency
318    }
319
320    fn isin(&self) -> Option<Ustr> {
321        None
322    }
323
324    fn exchange(&self) -> Option<Ustr> {
325        None
326    }
327
328    fn option_kind(&self) -> Option<OptionKind> {
329        None
330    }
331
332    fn is_inverse(&self) -> bool {
333        self.is_inverse
334    }
335
336    fn price_precision(&self) -> u8 {
337        self.price_precision
338    }
339
340    fn size_precision(&self) -> u8 {
341        self.size_precision
342    }
343
344    fn price_increment(&self) -> Price {
345        self.price_increment
346    }
347
348    fn size_increment(&self) -> Quantity {
349        self.size_increment
350    }
351
352    fn multiplier(&self) -> Quantity {
353        self.multiplier
354    }
355
356    fn lot_size(&self) -> Option<Quantity> {
357        Some(self.lot_size)
358    }
359
360    fn max_quantity(&self) -> Option<Quantity> {
361        self.max_quantity
362    }
363
364    fn min_quantity(&self) -> Option<Quantity> {
365        self.min_quantity
366    }
367
368    fn max_price(&self) -> Option<Price> {
369        self.max_price
370    }
371
372    fn min_price(&self) -> Option<Price> {
373        self.min_price
374    }
375
376    fn tick_scheme(&self) -> Option<Ustr> {
377        self.tick_scheme
378    }
379
380    fn info(&self) -> Option<&Params> {
381        self.info.as_ref()
382    }
383
384    fn ts_event(&self) -> UnixNanos {
385        self.ts_event
386    }
387
388    fn ts_init(&self) -> UnixNanos {
389        self.ts_init
390    }
391
392    fn margin_init(&self) -> Decimal {
393        self.margin_init
394    }
395
396    fn margin_maint(&self) -> Decimal {
397        self.margin_maint
398    }
399
400    fn maker_fee(&self) -> Decimal {
401        self.maker_fee
402    }
403
404    fn taker_fee(&self) -> Decimal {
405        self.taker_fee
406    }
407
408    fn strike_price(&self) -> Option<Price> {
409        None
410    }
411
412    fn activation_ns(&self) -> Option<UnixNanos> {
413        Some(self.activation_ns)
414    }
415
416    fn expiration_ns(&self) -> Option<UnixNanos> {
417        Some(self.expiration_ns)
418    }
419
420    fn max_notional(&self) -> Option<Money> {
421        self.max_notional
422    }
423
424    fn min_notional(&self) -> Option<Money> {
425        self.min_notional
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use rstest::rstest;
432    use rust_decimal_macros::dec;
433
434    use crate::{
435        enums::{AssetClass, InstrumentClass},
436        identifiers::{InstrumentId, Symbol},
437        instruments::{CryptoFuture, Instrument, stubs::*},
438        types::{Currency, Money, Price, Quantity},
439    };
440
441    #[rstest]
442    fn test_trait_accessors(crypto_future_btcusdt: CryptoFuture) {
443        assert_eq!(
444            crypto_future_btcusdt.id(),
445            InstrumentId::from("ETHUSDT-123.BINANCE")
446        );
447        assert_eq!(
448            crypto_future_btcusdt.asset_class(),
449            AssetClass::Cryptocurrency
450        );
451        assert_eq!(
452            crypto_future_btcusdt.instrument_class(),
453            InstrumentClass::Future
454        );
455        assert_eq!(crypto_future_btcusdt.quote_currency(), Currency::USDT());
456        assert_eq!(
457            crypto_future_btcusdt.settlement_currency(),
458            Currency::USDT()
459        );
460        assert!(!crypto_future_btcusdt.is_inverse());
461        assert_eq!(crypto_future_btcusdt.price_precision(), 2);
462        assert_eq!(crypto_future_btcusdt.size_precision(), 6);
463        assert!(crypto_future_btcusdt.activation_ns().is_some());
464        assert!(crypto_future_btcusdt.expiration_ns().is_some());
465    }
466
467    #[rstest]
468    fn test_new_checked_price_precision_mismatch() {
469        let result = CryptoFuture::new_checked(
470            InstrumentId::from("TEST.BINANCE"),
471            Symbol::from("TEST"),
472            Currency::BTC(),
473            Currency::USDT(),
474            Currency::USDT(),
475            false,
476            0.into(),
477            0.into(),
478            4, // mismatch
479            6,
480            Price::from("0.01"),
481            Quantity::from("0.000001"),
482            None,
483            None,
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 = CryptoFuture::new_checked(
510            InstrumentId::from("TEST.BINANCE"),
511            Symbol::from("TEST"),
512            Currency::BTC(),
513            Currency::USDT(),
514            Currency::USDT(),
515            false,
516            0.into(),
517            0.into(),
518            2,
519            6,
520            Price::from("0.01"),
521            Quantity::from("0.000001"),
522            multiplier,
523            lot_size,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            None,
536            0.into(),
537            0.into(),
538        );
539        let error = result.unwrap_err();
540        assert!(error.to_string().contains("not positive"), "{error}");
541    }
542
543    #[rstest]
544    fn test_serialization_roundtrip(crypto_future_btcusdt: CryptoFuture) {
545        let json = serde_json::to_string(&crypto_future_btcusdt).unwrap();
546        let deserialized: CryptoFuture = serde_json::from_str(&json).unwrap();
547        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
548    }
549
550    #[rstest]
551    fn test_builder_matches_new_checked() {
552        let positional = CryptoFuture::new_checked(
553            InstrumentId::from("ETHUSDT-123.BINANCE"),
554            Symbol::from("BTCUSDT"),
555            Currency::BTC(),
556            Currency::USDT(),
557            Currency::USDC(),
558            false,
559            1.into(),
560            2.into(),
561            2,
562            6,
563            Price::from("0.01"),
564            Quantity::from("0.000001"),
565            Some(Quantity::from("10")),
566            Some(Quantity::from("1")),
567            Some(Quantity::from("9000.0")),
568            Some(Quantity::from("0.000001")),
569            Some(Money::new(5_000_000.0, Currency::USDT())),
570            Some(Money::new(10.0, Currency::USDT())),
571            Some(Price::from("1000000.00")),
572            Some(Price::from("0.01")),
573            Some(dec!(0.01)),
574            Some(dec!(0.02)),
575            Some(dec!(0.0002)),
576            Some(dec!(0.0004)),
577            None,
578            None,
579            10.into(),
580            20.into(),
581        )
582        .unwrap();
583
584        let built = CryptoFuture::builder()
585            .instrument_id(InstrumentId::from("ETHUSDT-123.BINANCE"))
586            .raw_symbol(Symbol::from("BTCUSDT"))
587            .underlying(Currency::BTC())
588            .quote_currency(Currency::USDT())
589            .settlement_currency(Currency::USDC())
590            .is_inverse(false)
591            .activation_ns(1.into())
592            .expiration_ns(2.into())
593            .price_precision(2)
594            .size_precision(6)
595            .price_increment(Price::from("0.01"))
596            .size_increment(Quantity::from("0.000001"))
597            .multiplier(Quantity::from("10"))
598            .lot_size(Quantity::from("1"))
599            .max_quantity(Quantity::from("9000.0"))
600            .min_quantity(Quantity::from("0.000001"))
601            .max_notional(Money::new(5_000_000.0, Currency::USDT()))
602            .min_notional(Money::new(10.0, Currency::USDT()))
603            .max_price(Price::from("1000000.00"))
604            .min_price(Price::from("0.01"))
605            .margin_init(dec!(0.01))
606            .margin_maint(dec!(0.02))
607            .maker_fee(dec!(0.0002))
608            .taker_fee(dec!(0.0004))
609            .ts_event(10.into())
610            .ts_init(20.into())
611            .build()
612            .unwrap();
613
614        assert_eq!(
615            serde_json::to_value(&positional).unwrap(),
616            serde_json::to_value(&built).unwrap(),
617        );
618    }
619}