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