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