Skip to main content

nautilus_model/instruments/
futures_contract.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::{
21        CorrectnessResult, check_equal_u8, check_valid_string_ascii,
22        check_valid_string_ascii_optional,
23    },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
30use crate::{
31    enums::{AssetClass, InstrumentClass, OptionKind},
32    identifiers::{InstrumentId, Symbol},
33    types::{
34        currency::Currency,
35        money::Money,
36        price::{Price, check_positive_price},
37        quantity::{Quantity, check_positive_quantity},
38    },
39};
40
41/// Represents a generic deliverable futures contract instrument.
42#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45    feature = "python",
46    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
47)]
48#[cfg_attr(
49    feature = "python",
50    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
51)]
52pub struct FuturesContract {
53    /// The instrument ID.
54    pub id: InstrumentId,
55    /// The raw/local/native symbol for the instrument, assigned by the venue.
56    pub raw_symbol: Symbol,
57    /// The futures contract asset class.
58    pub asset_class: AssetClass,
59    /// The exchange ISO 10383 Market Identifier Code (MIC) where the instrument trades.
60    pub exchange: Option<Ustr>,
61    /// The underlying asset.
62    pub underlying: Ustr,
63    /// UNIX timestamp (nanoseconds) for contract activation.
64    pub activation_ns: UnixNanos,
65    /// UNIX timestamp (nanoseconds) for contract expiration.
66    pub expiration_ns: UnixNanos,
67    /// The futures contract currency.
68    pub currency: Currency,
69    /// The price decimal precision.
70    pub price_precision: u8,
71    /// The minimum price increment (tick size).
72    pub price_increment: Price,
73    /// The minimum size increment.
74    pub size_increment: Quantity,
75    /// The trading size decimal precision.
76    pub size_precision: u8,
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 fee rate for liquidity makers as a percentage of order value.
86    pub maker_fee: Decimal,
87    /// The fee rate for liquidity takers as a percentage of order value.
88    pub taker_fee: Decimal,
89    /// The maximum allowable order quantity.
90    pub max_quantity: Option<Quantity>,
91    /// The minimum allowable order quantity.
92    pub min_quantity: Option<Quantity>,
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 FuturesContract {
109    #[expect(clippy::too_many_arguments)]
110    fn new_checked(
111        instrument_id: InstrumentId,
112        raw_symbol: Symbol,
113        asset_class: AssetClass,
114        exchange: Option<Ustr>,
115        underlying: Ustr,
116        activation_ns: UnixNanos,
117        expiration_ns: UnixNanos,
118        currency: Currency,
119        price_precision: u8,
120        price_increment: Price,
121        multiplier: Quantity,
122        lot_size: Quantity,
123        max_quantity: Option<Quantity>,
124        min_quantity: Option<Quantity>,
125        max_price: Option<Price>,
126        min_price: Option<Price>,
127        margin_init: Option<Decimal>,
128        margin_maint: Option<Decimal>,
129        maker_fee: Option<Decimal>,
130        taker_fee: Option<Decimal>,
131        tick_scheme: Option<Ustr>,
132        info: Option<Params>,
133        ts_event: UnixNanos,
134        ts_init: UnixNanos,
135    ) -> CorrectnessResult<Self> {
136        check_valid_string_ascii_optional(exchange, stringify!(exchange))?;
137        check_valid_string_ascii(underlying, stringify!(underlying))?;
138        check_equal_u8(
139            price_precision,
140            price_increment.precision,
141            stringify!(price_precision),
142            stringify!(price_increment.precision),
143        )?;
144        check_positive_price(price_increment, stringify!(price_increment))?;
145        check_tick_scheme(tick_scheme)?;
146        check_positive_quantity(multiplier, stringify!(multiplier))?;
147        check_positive_quantity(lot_size, stringify!(lot_size))?;
148
149        Ok(Self {
150            id: instrument_id,
151            raw_symbol,
152            asset_class,
153            exchange,
154            underlying,
155            activation_ns,
156            expiration_ns,
157            currency,
158            price_precision,
159            price_increment,
160            size_precision: 0,
161            size_increment: Quantity::from(1),
162            multiplier,
163            lot_size,
164            max_quantity,
165            min_quantity: Some(min_quantity.unwrap_or(1.into())),
166            max_price,
167            min_price,
168            margin_init: margin_init.unwrap_or_default(),
169            margin_maint: margin_maint.unwrap_or_default(),
170            maker_fee: maker_fee.unwrap_or_default(),
171            taker_fee: taker_fee.unwrap_or_default(),
172            tick_scheme,
173            info,
174            ts_event,
175            ts_init,
176        })
177    }
178
179    /// Returns a fluent builder for a [`FuturesContract`] instance.
180    ///
181    /// Required fields are enforced at compile time; optional fields can be omitted and use the
182    /// same defaults as checked construction. The same correctness checks run on `build`.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if any input validation fails.
187    #[builder(start_fn = builder, finish_fn = build)]
188    pub fn build_checked(
189        instrument_id: InstrumentId,
190        raw_symbol: Symbol,
191        asset_class: AssetClass,
192        exchange: Option<Ustr>,
193        underlying: Ustr,
194        activation_ns: UnixNanos,
195        expiration_ns: UnixNanos,
196        currency: Currency,
197        price_precision: u8,
198        price_increment: Price,
199        multiplier: Quantity,
200        lot_size: Quantity,
201        max_quantity: Option<Quantity>,
202        min_quantity: Option<Quantity>,
203        max_price: Option<Price>,
204        min_price: Option<Price>,
205        margin_init: Option<Decimal>,
206        margin_maint: Option<Decimal>,
207        maker_fee: Option<Decimal>,
208        taker_fee: Option<Decimal>,
209        tick_scheme: Option<Ustr>,
210        info: Option<Params>,
211        ts_event: UnixNanos,
212        ts_init: UnixNanos,
213    ) -> CorrectnessResult<Self> {
214        Self::new_checked(
215            instrument_id,
216            raw_symbol,
217            asset_class,
218            exchange,
219            underlying,
220            activation_ns,
221            expiration_ns,
222            currency,
223            price_precision,
224            price_increment,
225            multiplier,
226            lot_size,
227            max_quantity,
228            min_quantity,
229            max_price,
230            min_price,
231            margin_init,
232            margin_maint,
233            maker_fee,
234            taker_fee,
235            tick_scheme,
236            info,
237            ts_event,
238            ts_init,
239        )
240    }
241}
242
243impl PartialEq<Self> for FuturesContract {
244    fn eq(&self, other: &Self) -> bool {
245        self.id == other.id
246    }
247}
248
249impl Eq for FuturesContract {}
250
251impl Hash for FuturesContract {
252    fn hash<H: Hasher>(&self, state: &mut H) {
253        self.id.hash(state);
254    }
255}
256
257impl Instrument for FuturesContract {
258    fn into_any(self) -> InstrumentAny {
259        InstrumentAny::FuturesContract(self)
260    }
261
262    fn id(&self) -> InstrumentId {
263        self.id
264    }
265
266    fn raw_symbol(&self) -> Symbol {
267        self.raw_symbol
268    }
269
270    fn asset_class(&self) -> AssetClass {
271        self.asset_class
272    }
273
274    fn instrument_class(&self) -> InstrumentClass {
275        InstrumentClass::Future
276    }
277    fn underlying(&self) -> Option<Ustr> {
278        Some(self.underlying)
279    }
280
281    fn base_currency(&self) -> Option<Currency> {
282        None
283    }
284
285    fn quote_currency(&self) -> Currency {
286        self.currency
287    }
288
289    fn settlement_currency(&self) -> Currency {
290        self.currency
291    }
292
293    fn isin(&self) -> Option<Ustr> {
294        None
295    }
296
297    fn option_kind(&self) -> Option<OptionKind> {
298        None
299    }
300
301    fn exchange(&self) -> Option<Ustr> {
302        self.exchange
303    }
304
305    fn strike_price(&self) -> Option<Price> {
306        None
307    }
308
309    fn activation_ns(&self) -> Option<UnixNanos> {
310        Some(self.activation_ns)
311    }
312
313    fn expiration_ns(&self) -> Option<UnixNanos> {
314        Some(self.expiration_ns)
315    }
316
317    fn is_inverse(&self) -> bool {
318        false
319    }
320
321    fn price_precision(&self) -> u8 {
322        self.price_precision
323    }
324
325    fn size_precision(&self) -> u8 {
326        0
327    }
328
329    fn price_increment(&self) -> Price {
330        self.price_increment
331    }
332
333    fn size_increment(&self) -> Quantity {
334        Quantity::from(1)
335    }
336
337    fn multiplier(&self) -> Quantity {
338        self.multiplier
339    }
340
341    fn lot_size(&self) -> Option<Quantity> {
342        Some(self.lot_size)
343    }
344
345    fn max_quantity(&self) -> Option<Quantity> {
346        self.max_quantity
347    }
348
349    fn min_quantity(&self) -> Option<Quantity> {
350        self.min_quantity
351    }
352
353    fn max_notional(&self) -> Option<Money> {
354        None
355    }
356
357    fn min_notional(&self) -> Option<Money> {
358        None
359    }
360
361    fn max_price(&self) -> Option<Price> {
362        self.max_price
363    }
364
365    fn min_price(&self) -> Option<Price> {
366        self.min_price
367    }
368
369    fn tick_scheme(&self) -> Option<Ustr> {
370        self.tick_scheme
371    }
372
373    fn info(&self) -> Option<&Params> {
374        self.info.as_ref()
375    }
376
377    fn ts_event(&self) -> UnixNanos {
378        self.ts_event
379    }
380
381    fn ts_init(&self) -> UnixNanos {
382        self.ts_init
383    }
384
385    fn margin_init(&self) -> Decimal {
386        self.margin_init
387    }
388
389    fn margin_maint(&self) -> Decimal {
390        self.margin_maint
391    }
392
393    fn maker_fee(&self) -> Decimal {
394        self.maker_fee
395    }
396
397    fn taker_fee(&self) -> Decimal {
398        self.taker_fee
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use rstest::rstest;
405    use rust_decimal_macros::dec;
406    use ustr::Ustr;
407
408    use crate::{
409        enums::{AssetClass, InstrumentClass},
410        identifiers::{InstrumentId, Symbol},
411        instruments::{FuturesContract, Instrument, stubs::*},
412        types::{Currency, Price, Quantity},
413    };
414
415    #[rstest]
416    fn test_trait_accessors() {
417        let inst = futures_contract_es(None, None);
418        assert_eq!(inst.id(), InstrumentId::from("ESZ21.GLBX"));
419        assert_eq!(inst.raw_symbol(), Symbol::from("ESZ21"));
420        assert_eq!(inst.asset_class(), AssetClass::Index);
421        assert_eq!(inst.instrument_class(), InstrumentClass::Future);
422        assert_eq!(inst.quote_currency(), Currency::USD());
423        assert!(!inst.is_inverse());
424        assert_eq!(inst.price_precision(), 2);
425        assert_eq!(inst.size_precision(), 0);
426        assert_eq!(inst.price_increment(), Price::from("0.01"));
427        assert_eq!(inst.size_increment(), Quantity::from("1"));
428        assert_eq!(inst.multiplier(), Quantity::from("1"));
429        assert_eq!(inst.lot_size(), Some(Quantity::from("1")));
430        assert_eq!(inst.underlying(), Some(Ustr::from("ES")));
431        assert_eq!(inst.exchange(), Some(Ustr::from("XCME")));
432        assert!(inst.activation_ns().is_some());
433        assert!(inst.expiration_ns().is_some());
434        assert_eq!(inst.min_quantity(), Some(Quantity::from("1")));
435    }
436
437    #[rstest]
438    fn test_new_checked_price_precision_mismatch() {
439        let result = FuturesContract::new_checked(
440            InstrumentId::from("ESZ21.GLBX"),
441            Symbol::from("ESZ21"),
442            AssetClass::Index,
443            Some(Ustr::from("XCME")),
444            Ustr::from("ES"),
445            0.into(),
446            0.into(),
447            Currency::USD(),
448            4, // mismatch
449            Price::from("0.01"),
450            Quantity::from(1),
451            Quantity::from(1),
452            None,
453            None,
454            None,
455            None,
456            None,
457            None,
458            None,
459            None,
460            None,
461            None,
462            0.into(),
463            0.into(),
464        );
465        assert!(result.is_err());
466    }
467
468    #[rstest]
469    fn test_new_checked_zero_multiplier() {
470        let result = FuturesContract::new_checked(
471            InstrumentId::from("ESZ21.GLBX"),
472            Symbol::from("ESZ21"),
473            AssetClass::Index,
474            Some(Ustr::from("XCME")),
475            Ustr::from("ES"),
476            0.into(),
477            0.into(),
478            Currency::USD(),
479            2,
480            Price::from("0.01"),
481            Quantity::from("0"), // zero multiplier
482            Quantity::from(1),
483            None,
484            None,
485            None,
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            0.into(),
494            0.into(),
495        );
496        assert!(result.is_err());
497    }
498
499    #[rstest]
500    fn test_new_checked_zero_lot_size() {
501        let result = FuturesContract::new_checked(
502            InstrumentId::from("ESZ21.GLBX"),
503            Symbol::from("ESZ21"),
504            AssetClass::Index,
505            Some(Ustr::from("XCME")),
506            Ustr::from("ES"),
507            0.into(),
508            0.into(),
509            Currency::USD(),
510            2,
511            Price::from("0.01"),
512            Quantity::from(1),
513            Quantity::from("0"), // zero lot_size
514            None,
515            None,
516            None,
517            None,
518            None,
519            None,
520            None,
521            None,
522            None,
523            None,
524            0.into(),
525            0.into(),
526        );
527        assert!(result.is_err());
528    }
529
530    #[rstest]
531    fn test_serialization_roundtrip() {
532        let inst = futures_contract_es(None, None);
533        let json = serde_json::to_string(&inst).unwrap();
534        let deserialized: FuturesContract = serde_json::from_str(&json).unwrap();
535        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
536    }
537
538    #[rstest]
539    fn test_builder_matches_new_checked() {
540        let positional = FuturesContract::new_checked(
541            InstrumentId::from("ESZ21.GLBX"),
542            Symbol::from("ESZ21"),
543            AssetClass::Index,
544            Some(Ustr::from("XCME")),
545            Ustr::from("ES"),
546            1_000.into(),
547            2_000.into(),
548            Currency::USD(),
549            2,
550            Price::from("0.01"),
551            Quantity::from(50),
552            Quantity::from(10),
553            Some(Quantity::from("10000")),
554            Some(Quantity::from("5")),
555            Some(Price::from("9999.99")),
556            Some(Price::from("0.01")),
557            Some(dec!(0.01)),
558            Some(dec!(0.02)),
559            Some(dec!(0.0002)),
560            Some(dec!(0.0004)),
561            None,
562            None,
563            1.into(),
564            2.into(),
565        )
566        .unwrap();
567
568        let built = FuturesContract::builder()
569            .instrument_id(InstrumentId::from("ESZ21.GLBX"))
570            .raw_symbol(Symbol::from("ESZ21"))
571            .asset_class(AssetClass::Index)
572            .exchange(Ustr::from("XCME"))
573            .underlying(Ustr::from("ES"))
574            .activation_ns(1_000.into())
575            .expiration_ns(2_000.into())
576            .currency(Currency::USD())
577            .price_precision(2)
578            .price_increment(Price::from("0.01"))
579            .multiplier(Quantity::from(50))
580            .lot_size(Quantity::from(10))
581            .max_quantity(Quantity::from("10000"))
582            .min_quantity(Quantity::from("5"))
583            .max_price(Price::from("9999.99"))
584            .min_price(Price::from("0.01"))
585            .margin_init(dec!(0.01))
586            .margin_maint(dec!(0.02))
587            .maker_fee(dec!(0.0002))
588            .taker_fee(dec!(0.0004))
589            .ts_event(1.into())
590            .ts_init(2.into())
591            .build()
592            .unwrap();
593
594        assert_eq!(
595            serde_json::to_value(&positional).unwrap(),
596            serde_json::to_value(&built).unwrap(),
597        );
598    }
599}