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};
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    /// Additional instrument metadata as a JSON-serializable dictionary.
98    pub info: Option<Params>,
99    /// UNIX timestamp (nanoseconds) when the data event occurred.
100    pub ts_event: UnixNanos,
101    /// UNIX timestamp (nanoseconds) when the data object was initialized.
102    pub ts_init: UnixNanos,
103}
104
105impl FuturesContract {
106    /// Creates a new [`FuturesContract`] instance with correctness checking.
107    ///
108    /// # Notes
109    ///
110    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
111    /// # Errors
112    ///
113    /// Returns an error if any input validation fails.
114    #[expect(clippy::too_many_arguments)]
115    pub fn new_checked(
116        instrument_id: InstrumentId,
117        raw_symbol: Symbol,
118        asset_class: AssetClass,
119        exchange: Option<Ustr>,
120        underlying: Ustr,
121        activation_ns: UnixNanos,
122        expiration_ns: UnixNanos,
123        currency: Currency,
124        price_precision: u8,
125        price_increment: Price,
126        multiplier: Quantity,
127        lot_size: Quantity,
128        max_quantity: Option<Quantity>,
129        min_quantity: Option<Quantity>,
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        info: Option<Params>,
137        ts_event: UnixNanos,
138        ts_init: UnixNanos,
139    ) -> CorrectnessResult<Self> {
140        check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
141        check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
142        check_equal_u8(
143            price_precision,
144            price_increment.precision,
145            stringify!(price_precision),
146            stringify!(price_increment.precision),
147        )?;
148        check_positive_price(price_increment, stringify!(price_increment))?;
149        check_positive_quantity(multiplier, stringify!(multiplier))?;
150        check_positive_quantity(lot_size, stringify!(lot_size))?;
151
152        Ok(Self {
153            id: instrument_id,
154            raw_symbol,
155            asset_class,
156            exchange,
157            underlying,
158            activation_ns,
159            expiration_ns,
160            currency,
161            price_precision,
162            price_increment,
163            size_precision: 0,
164            size_increment: Quantity::from(1),
165            multiplier,
166            lot_size,
167            max_quantity,
168            min_quantity: Some(min_quantity.unwrap_or(1.into())),
169            max_price,
170            min_price,
171            margin_init: margin_init.unwrap_or_default(),
172            margin_maint: margin_maint.unwrap_or_default(),
173            maker_fee: maker_fee.unwrap_or_default(),
174            taker_fee: taker_fee.unwrap_or_default(),
175            info,
176            ts_event,
177            ts_init,
178        })
179    }
180
181    /// Creates a new [`FuturesContract`] instance.
182    ///
183    /// # Panics
184    ///
185    /// Panics if any input parameter is invalid (see `new_checked`).
186    #[expect(clippy::too_many_arguments)]
187    #[must_use]
188    pub fn new(
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        info: Option<Params>,
210        ts_event: UnixNanos,
211        ts_init: UnixNanos,
212    ) -> Self {
213        Self::new_checked(
214            instrument_id,
215            raw_symbol,
216            asset_class,
217            exchange,
218            underlying,
219            activation_ns,
220            expiration_ns,
221            currency,
222            price_precision,
223            price_increment,
224            multiplier,
225            lot_size,
226            max_quantity,
227            min_quantity,
228            max_price,
229            min_price,
230            margin_init,
231            margin_maint,
232            maker_fee,
233            taker_fee,
234            info,
235            ts_event,
236            ts_init,
237        )
238        .expect_display(FAILED)
239    }
240}
241
242impl PartialEq<Self> for FuturesContract {
243    fn eq(&self, other: &Self) -> bool {
244        self.id == other.id
245    }
246}
247
248impl Eq for FuturesContract {}
249
250impl Hash for FuturesContract {
251    fn hash<H: Hasher>(&self, state: &mut H) {
252        self.id.hash(state);
253    }
254}
255
256impl Instrument for FuturesContract {
257    fn into_any(self) -> InstrumentAny {
258        InstrumentAny::FuturesContract(self)
259    }
260
261    fn id(&self) -> InstrumentId {
262        self.id
263    }
264
265    fn raw_symbol(&self) -> Symbol {
266        self.raw_symbol
267    }
268
269    fn asset_class(&self) -> AssetClass {
270        self.asset_class
271    }
272
273    fn instrument_class(&self) -> InstrumentClass {
274        InstrumentClass::Future
275    }
276    fn underlying(&self) -> Option<Ustr> {
277        Some(self.underlying)
278    }
279
280    fn base_currency(&self) -> Option<Currency> {
281        None
282    }
283
284    fn quote_currency(&self) -> Currency {
285        self.currency
286    }
287
288    fn settlement_currency(&self) -> Currency {
289        self.currency
290    }
291
292    fn isin(&self) -> Option<Ustr> {
293        None
294    }
295
296    fn option_kind(&self) -> Option<OptionKind> {
297        None
298    }
299
300    fn exchange(&self) -> Option<Ustr> {
301        self.exchange
302    }
303
304    fn strike_price(&self) -> Option<Price> {
305        None
306    }
307
308    fn activation_ns(&self) -> Option<UnixNanos> {
309        Some(self.activation_ns)
310    }
311
312    fn expiration_ns(&self) -> Option<UnixNanos> {
313        Some(self.expiration_ns)
314    }
315
316    fn is_inverse(&self) -> bool {
317        false
318    }
319
320    fn price_precision(&self) -> u8 {
321        self.price_precision
322    }
323
324    fn size_precision(&self) -> u8 {
325        0
326    }
327
328    fn price_increment(&self) -> Price {
329        self.price_increment
330    }
331
332    fn size_increment(&self) -> Quantity {
333        Quantity::from(1)
334    }
335
336    fn multiplier(&self) -> Quantity {
337        self.multiplier
338    }
339
340    fn lot_size(&self) -> Option<Quantity> {
341        Some(self.lot_size)
342    }
343
344    fn max_quantity(&self) -> Option<Quantity> {
345        self.max_quantity
346    }
347
348    fn min_quantity(&self) -> Option<Quantity> {
349        self.min_quantity
350    }
351
352    fn max_notional(&self) -> Option<Money> {
353        None
354    }
355
356    fn min_notional(&self) -> Option<Money> {
357        None
358    }
359
360    fn max_price(&self) -> Option<Price> {
361        self.max_price
362    }
363
364    fn min_price(&self) -> Option<Price> {
365        self.min_price
366    }
367
368    fn ts_event(&self) -> UnixNanos {
369        self.ts_event
370    }
371
372    fn ts_init(&self) -> UnixNanos {
373        self.ts_init
374    }
375
376    fn margin_init(&self) -> Decimal {
377        self.margin_init
378    }
379
380    fn margin_maint(&self) -> Decimal {
381        self.margin_maint
382    }
383
384    fn maker_fee(&self) -> Decimal {
385        self.maker_fee
386    }
387
388    fn taker_fee(&self) -> Decimal {
389        self.taker_fee
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use rstest::rstest;
396    use ustr::Ustr;
397
398    use crate::{
399        enums::{AssetClass, InstrumentClass},
400        identifiers::{InstrumentId, Symbol},
401        instruments::{FuturesContract, Instrument, stubs::*},
402        types::{Currency, Price, Quantity},
403    };
404
405    #[rstest]
406    fn test_trait_accessors() {
407        let inst = futures_contract_es(None, None);
408        assert_eq!(inst.id(), InstrumentId::from("ESZ21.GLBX"));
409        assert_eq!(inst.raw_symbol(), Symbol::from("ESZ21"));
410        assert_eq!(inst.asset_class(), AssetClass::Index);
411        assert_eq!(inst.instrument_class(), InstrumentClass::Future);
412        assert_eq!(inst.quote_currency(), Currency::USD());
413        assert!(!inst.is_inverse());
414        assert_eq!(inst.price_precision(), 2);
415        assert_eq!(inst.size_precision(), 0);
416        assert_eq!(inst.price_increment(), Price::from("0.01"));
417        assert_eq!(inst.size_increment(), Quantity::from("1"));
418        assert_eq!(inst.multiplier(), Quantity::from("1"));
419        assert_eq!(inst.lot_size(), Some(Quantity::from("1")));
420        assert_eq!(inst.underlying(), Some(Ustr::from("ES")));
421        assert_eq!(inst.exchange(), Some(Ustr::from("XCME")));
422        assert!(inst.activation_ns().is_some());
423        assert!(inst.expiration_ns().is_some());
424        assert_eq!(inst.min_quantity(), Some(Quantity::from("1")));
425    }
426
427    #[rstest]
428    fn test_new_checked_price_precision_mismatch() {
429        let result = FuturesContract::new_checked(
430            InstrumentId::from("ESZ21.GLBX"),
431            Symbol::from("ESZ21"),
432            AssetClass::Index,
433            Some(Ustr::from("XCME")),
434            Ustr::from("ES"),
435            0.into(),
436            0.into(),
437            Currency::USD(),
438            4, // mismatch
439            Price::from("0.01"),
440            Quantity::from(1),
441            Quantity::from(1),
442            None,
443            None,
444            None,
445            None,
446            None,
447            None,
448            None,
449            None,
450            None,
451            0.into(),
452            0.into(),
453        );
454        assert!(result.is_err());
455    }
456
457    #[rstest]
458    fn test_new_checked_zero_multiplier() {
459        let result = FuturesContract::new_checked(
460            InstrumentId::from("ESZ21.GLBX"),
461            Symbol::from("ESZ21"),
462            AssetClass::Index,
463            Some(Ustr::from("XCME")),
464            Ustr::from("ES"),
465            0.into(),
466            0.into(),
467            Currency::USD(),
468            2,
469            Price::from("0.01"),
470            Quantity::from("0"), // zero multiplier
471            Quantity::from(1),
472            None,
473            None,
474            None,
475            None,
476            None,
477            None,
478            None,
479            None,
480            None,
481            0.into(),
482            0.into(),
483        );
484        assert!(result.is_err());
485    }
486
487    #[rstest]
488    fn test_new_checked_zero_lot_size() {
489        let result = FuturesContract::new_checked(
490            InstrumentId::from("ESZ21.GLBX"),
491            Symbol::from("ESZ21"),
492            AssetClass::Index,
493            Some(Ustr::from("XCME")),
494            Ustr::from("ES"),
495            0.into(),
496            0.into(),
497            Currency::USD(),
498            2,
499            Price::from("0.01"),
500            Quantity::from(1),
501            Quantity::from("0"), // zero lot_size
502            None,
503            None,
504            None,
505            None,
506            None,
507            None,
508            None,
509            None,
510            None,
511            0.into(),
512            0.into(),
513        );
514        assert!(result.is_err());
515    }
516
517    #[rstest]
518    fn test_serialization_roundtrip() {
519        let inst = futures_contract_es(None, None);
520        let json = serde_json::to_string(&inst).unwrap();
521        let deserialized: FuturesContract = serde_json::from_str(&json).unwrap();
522        assert_eq!(inst, deserialized);
523    }
524}