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