Skip to main content

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