Skip to main content

nautilus_model/instruments/
perpetual_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::{CorrectnessError, CorrectnessResult, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
27use crate::{
28    enums::{AssetClass, InstrumentClass, OptionKind},
29    identifiers::{InstrumentId, Symbol},
30    types::{
31        currency::Currency,
32        money::Money,
33        price::{Price, check_positive_price},
34        quantity::{Quantity, check_positive_quantity},
35    },
36};
37
38/// Represents a perpetual contract instrument (perpetual swap).
39///
40/// Supports perpetuals on any asset class including FX, equities,
41/// commodities, indexes, and cryptocurrencies.
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 PerpetualContract {
53    /// The instrument ID for the instrument.
54    pub id: InstrumentId,
55    /// The raw/local/native symbol for the instrument, assigned by the venue.
56    pub raw_symbol: Symbol,
57    /// The underlying asset identifier (e.g., "EURUSD", "NVDA", "GC").
58    pub underlying: Ustr,
59    /// The asset class of the perpetual contract.
60    pub asset_class: AssetClass,
61    /// The base currency (optional, set for FX/crypto underlyings).
62    pub base_currency: Option<Currency>,
63    /// The quote currency.
64    pub quote_currency: Currency,
65    /// The settlement currency.
66    pub settlement_currency: Currency,
67    /// If the instrument costing is inverse (quantity expressed in quote currency units).
68    pub is_inverse: bool,
69    /// The price decimal precision.
70    pub price_precision: u8,
71    /// The trading size decimal precision.
72    pub size_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 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 order notional value.
90    pub max_notional: Option<Money>,
91    /// The minimum allowable order notional value.
92    pub min_notional: Option<Money>,
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 PerpetualContract {
109    #[expect(clippy::too_many_arguments)]
110    fn new_checked(
111        instrument_id: InstrumentId,
112        raw_symbol: Symbol,
113        underlying: Ustr,
114        asset_class: AssetClass,
115        base_currency: Option<Currency>,
116        quote_currency: Currency,
117        settlement_currency: Currency,
118        is_inverse: bool,
119        price_precision: u8,
120        size_precision: u8,
121        price_increment: Price,
122        size_increment: Quantity,
123        multiplier: Option<Quantity>,
124        lot_size: Option<Quantity>,
125        max_quantity: Option<Quantity>,
126        min_quantity: Option<Quantity>,
127        max_notional: Option<Money>,
128        min_notional: Option<Money>,
129        max_price: Option<Price>,
130        min_price: Option<Price>,
131        margin_init: Option<Decimal>,
132        margin_maint: Option<Decimal>,
133        tick_scheme: Option<Ustr>,
134        info: Option<Params>,
135        ts_event: UnixNanos,
136        ts_init: UnixNanos,
137    ) -> CorrectnessResult<Self> {
138        check_equal_u8(
139            price_precision,
140            price_increment.precision,
141            stringify!(price_precision),
142            stringify!(price_increment.precision),
143        )?;
144        check_equal_u8(
145            size_precision,
146            size_increment.precision,
147            stringify!(size_precision),
148            stringify!(size_increment.precision),
149        )?;
150        check_positive_price(price_increment, stringify!(price_increment))?;
151        check_positive_quantity(size_increment, stringify!(size_increment))?;
152        check_tick_scheme(tick_scheme)?;
153
154        if is_inverse && base_currency.is_none() {
155            return Err(CorrectnessError::PredicateViolation {
156                message: "Inverse perpetual contract requires a `base_currency`".to_string(),
157            });
158        }
159
160        if let Some(multiplier) = multiplier {
161            check_positive_quantity(multiplier, stringify!(multiplier))?;
162        }
163
164        if let Some(lot_size) = lot_size {
165            check_positive_quantity(lot_size, stringify!(lot_size))?;
166        }
167
168        Ok(Self {
169            id: instrument_id,
170            raw_symbol,
171            underlying,
172            asset_class,
173            base_currency,
174            quote_currency,
175            settlement_currency,
176            is_inverse,
177            price_precision,
178            size_precision,
179            price_increment,
180            size_increment,
181            multiplier: multiplier.unwrap_or(Quantity::from(1)),
182            lot_size: lot_size.unwrap_or(Quantity::from(1)),
183            margin_init: margin_init.unwrap_or_default(),
184            margin_maint: margin_maint.unwrap_or_default(),
185            max_quantity,
186            min_quantity,
187            max_notional,
188            min_notional,
189            max_price,
190            min_price,
191            tick_scheme,
192            info,
193            ts_event,
194            ts_init,
195        })
196    }
197
198    /// Returns a fluent builder for a [`PerpetualContract`] instance.
199    ///
200    /// Required fields are enforced at compile time; optional fields can be omitted and use the
201    /// same defaults as checked construction. The same correctness checks run on `build`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if any input validation fails.
206    #[builder(start_fn = builder, finish_fn = build)]
207    pub fn build_checked(
208        instrument_id: InstrumentId,
209        raw_symbol: Symbol,
210        underlying: Ustr,
211        asset_class: AssetClass,
212        base_currency: Option<Currency>,
213        quote_currency: Currency,
214        settlement_currency: Currency,
215        is_inverse: bool,
216        price_precision: u8,
217        size_precision: u8,
218        price_increment: Price,
219        size_increment: Quantity,
220        multiplier: Option<Quantity>,
221        lot_size: Option<Quantity>,
222        max_quantity: Option<Quantity>,
223        min_quantity: Option<Quantity>,
224        max_notional: Option<Money>,
225        min_notional: Option<Money>,
226        max_price: Option<Price>,
227        min_price: Option<Price>,
228        margin_init: Option<Decimal>,
229        margin_maint: Option<Decimal>,
230        tick_scheme: Option<Ustr>,
231        info: Option<Params>,
232        ts_event: UnixNanos,
233        ts_init: UnixNanos,
234    ) -> CorrectnessResult<Self> {
235        Self::new_checked(
236            instrument_id,
237            raw_symbol,
238            underlying,
239            asset_class,
240            base_currency,
241            quote_currency,
242            settlement_currency,
243            is_inverse,
244            price_precision,
245            size_precision,
246            price_increment,
247            size_increment,
248            multiplier,
249            lot_size,
250            max_quantity,
251            min_quantity,
252            max_notional,
253            min_notional,
254            max_price,
255            min_price,
256            margin_init,
257            margin_maint,
258            tick_scheme,
259            info,
260            ts_event,
261            ts_init,
262        )
263    }
264}
265
266impl PartialEq<Self> for PerpetualContract {
267    fn eq(&self, other: &Self) -> bool {
268        self.id == other.id
269    }
270}
271
272impl Eq for PerpetualContract {}
273
274impl Hash for PerpetualContract {
275    fn hash<H: Hasher>(&self, state: &mut H) {
276        self.id.hash(state);
277    }
278}
279
280impl Instrument for PerpetualContract {
281    fn into_any(self) -> InstrumentAny {
282        InstrumentAny::PerpetualContract(self)
283    }
284
285    fn id(&self) -> InstrumentId {
286        self.id
287    }
288
289    fn raw_symbol(&self) -> Symbol {
290        self.raw_symbol
291    }
292
293    fn asset_class(&self) -> AssetClass {
294        self.asset_class
295    }
296
297    fn instrument_class(&self) -> InstrumentClass {
298        InstrumentClass::Swap
299    }
300
301    fn underlying(&self) -> Option<Ustr> {
302        Some(self.underlying)
303    }
304
305    fn base_currency(&self) -> Option<Currency> {
306        self.base_currency
307    }
308
309    fn quote_currency(&self) -> Currency {
310        self.quote_currency
311    }
312
313    fn settlement_currency(&self) -> Currency {
314        self.settlement_currency
315    }
316
317    fn isin(&self) -> Option<Ustr> {
318        None
319    }
320
321    fn option_kind(&self) -> Option<OptionKind> {
322        None
323    }
324
325    fn exchange(&self) -> Option<Ustr> {
326        None
327    }
328
329    fn strike_price(&self) -> Option<Price> {
330        None
331    }
332
333    fn activation_ns(&self) -> Option<UnixNanos> {
334        None
335    }
336
337    fn expiration_ns(&self) -> Option<UnixNanos> {
338        None
339    }
340
341    fn is_inverse(&self) -> bool {
342        self.is_inverse
343    }
344
345    fn price_precision(&self) -> u8 {
346        self.price_precision
347    }
348
349    fn size_precision(&self) -> u8 {
350        self.size_precision
351    }
352
353    fn price_increment(&self) -> Price {
354        self.price_increment
355    }
356
357    fn size_increment(&self) -> Quantity {
358        self.size_increment
359    }
360
361    fn multiplier(&self) -> Quantity {
362        self.multiplier
363    }
364
365    fn lot_size(&self) -> Option<Quantity> {
366        Some(self.lot_size)
367    }
368
369    fn max_quantity(&self) -> Option<Quantity> {
370        self.max_quantity
371    }
372
373    fn min_quantity(&self) -> Option<Quantity> {
374        self.min_quantity
375    }
376
377    fn max_notional(&self) -> Option<Money> {
378        self.max_notional
379    }
380
381    fn min_notional(&self) -> Option<Money> {
382        self.min_notional
383    }
384
385    fn max_price(&self) -> Option<Price> {
386        self.max_price
387    }
388
389    fn min_price(&self) -> Option<Price> {
390        self.min_price
391    }
392
393    fn margin_init(&self) -> Decimal {
394        self.margin_init
395    }
396
397    fn margin_maint(&self) -> Decimal {
398        self.margin_maint
399    }
400
401    fn tick_scheme(&self) -> Option<Ustr> {
402        self.tick_scheme
403    }
404
405    fn info(&self) -> Option<&Params> {
406        self.info.as_ref()
407    }
408
409    fn ts_event(&self) -> UnixNanos {
410        self.ts_event
411    }
412
413    fn ts_init(&self) -> UnixNanos {
414        self.ts_init
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use rstest::rstest;
421    use rust_decimal_macros::dec;
422    use ustr::Ustr;
423
424    use crate::{
425        enums::{AssetClass, InstrumentClass},
426        identifiers::{InstrumentId, Symbol},
427        instruments::{Instrument, PerpetualContract, stubs::*},
428        types::{Currency, Money, Price, Quantity},
429    };
430
431    #[rstest]
432    fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
433        assert_eq!(
434            perpetual_contract_eurusd.id(),
435            InstrumentId::from("EURUSD-PERP.AX"),
436        );
437        assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
438        assert_eq!(
439            perpetual_contract_eurusd.instrument_class(),
440            InstrumentClass::Swap
441        );
442        assert_eq!(
443            perpetual_contract_eurusd.base_currency(),
444            Some(Currency::EUR())
445        );
446        assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
447        assert_eq!(
448            perpetual_contract_eurusd.settlement_currency(),
449            Currency::USD()
450        );
451        assert!(!perpetual_contract_eurusd.is_inverse());
452        assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
453        assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
454        assert_eq!(
455            perpetual_contract_eurusd.price_increment(),
456            Price::from("0.00001")
457        );
458        assert_eq!(
459            perpetual_contract_eurusd.size_increment(),
460            Quantity::from("1")
461        );
462        assert_eq!(
463            perpetual_contract_eurusd.underlying(),
464            Some(Ustr::from("EURUSD")),
465        );
466    }
467
468    #[rstest]
469    fn test_new_checked_inverse_without_base_currency() {
470        let result = PerpetualContract::new_checked(
471            InstrumentId::from("TEST.EXCHANGE"),
472            Symbol::from("TEST"),
473            Ustr::from("TEST"),
474            AssetClass::FX,
475            None, // no base_currency
476            Currency::USD(),
477            Currency::USD(),
478            true, // is_inverse
479            5,
480            0,
481            Price::from("0.00001"),
482            Quantity::from("1"),
483            None,
484            None,
485            None,
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            None,
494            None,
495            0.into(),
496            0.into(),
497        );
498        assert!(result.is_err());
499        assert!(result.unwrap_err().to_string().contains("base_currency"),);
500    }
501
502    #[rstest]
503    fn test_new_checked_price_precision_mismatch() {
504        let result = PerpetualContract::new_checked(
505            InstrumentId::from("TEST.EXCHANGE"),
506            Symbol::from("TEST"),
507            Ustr::from("TEST"),
508            AssetClass::FX,
509            Some(Currency::EUR()),
510            Currency::USD(),
511            Currency::USD(),
512            false,
513            3, // mismatch
514            0,
515            Price::from("0.00001"),
516            Quantity::from("1"),
517            None,
518            None,
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    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
537    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
538    fn test_new_checked_rejects_non_positive_sizing(
539        #[case] multiplier: Option<Quantity>,
540        #[case] lot_size: Option<Quantity>,
541    ) {
542        let result = PerpetualContract::new_checked(
543            InstrumentId::from("TEST.EXCHANGE"),
544            Symbol::from("TEST"),
545            Ustr::from("TEST"),
546            AssetClass::FX,
547            Some(Currency::EUR()),
548            Currency::USD(),
549            Currency::USD(),
550            false,
551            5,
552            0,
553            Price::from("0.00001"),
554            Quantity::from("1"),
555            multiplier,
556            lot_size,
557            None,
558            None,
559            None,
560            None,
561            None,
562            None,
563            None,
564            None,
565            None,
566            None,
567            0.into(),
568            0.into(),
569        );
570        let error = result.unwrap_err();
571        assert!(error.to_string().contains("not positive"), "{error}");
572    }
573
574    #[rstest]
575    fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
576        let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
577        let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
578        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
579    }
580
581    #[rstest]
582    fn test_builder_matches_new_checked() {
583        let positional = PerpetualContract::new_checked(
584            InstrumentId::from("EURUSD-PERP.AX"),
585            Symbol::from("EURUSD-PERP"),
586            Ustr::from("EURUSD"),
587            AssetClass::FX,
588            Some(Currency::EUR()),
589            Currency::USD(),
590            Currency::BTC(),
591            false,
592            5,
593            0,
594            Price::from("0.00001"),
595            Quantity::from("1"),
596            Some(Quantity::from("10")),
597            Some(Quantity::from("5")),
598            Some(Quantity::from("100")),
599            Some(Quantity::from("1")),
600            Some(Money::new(1000.0, Currency::USD())),
601            Some(Money::new(10.0, Currency::USD())),
602            Some(Price::from("9.99999")),
603            Some(Price::from("0.00002")),
604            Some(dec!(0.01)),
605            Some(dec!(0.02)),
606            None,
607            None,
608            1.into(),
609            2.into(),
610        )
611        .unwrap();
612
613        let built = PerpetualContract::builder()
614            .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
615            .raw_symbol(Symbol::from("EURUSD-PERP"))
616            .underlying(Ustr::from("EURUSD"))
617            .asset_class(AssetClass::FX)
618            .base_currency(Currency::EUR())
619            .quote_currency(Currency::USD())
620            .settlement_currency(Currency::BTC())
621            .is_inverse(false)
622            .price_precision(5)
623            .size_precision(0)
624            .price_increment(Price::from("0.00001"))
625            .size_increment(Quantity::from("1"))
626            .multiplier(Quantity::from("10"))
627            .lot_size(Quantity::from("5"))
628            .max_quantity(Quantity::from("100"))
629            .min_quantity(Quantity::from("1"))
630            .max_notional(Money::new(1000.0, Currency::USD()))
631            .min_notional(Money::new(10.0, Currency::USD()))
632            .max_price(Price::from("9.99999"))
633            .min_price(Price::from("0.00002"))
634            .margin_init(dec!(0.01))
635            .margin_maint(dec!(0.02))
636            .ts_event(1.into())
637            .ts_init(2.into())
638            .build()
639            .unwrap();
640
641        assert_eq!(
642            serde_json::to_value(&positional).unwrap(),
643            serde_json::to_value(&built).unwrap(),
644        );
645    }
646}