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::{
21        CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8,
22    },
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use ustr::Ustr;
27
28use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
29use crate::{
30    enums::{AssetClass, InstrumentClass, OptionKind},
31    identifiers::{InstrumentId, Symbol},
32    types::{
33        currency::Currency,
34        money::Money,
35        price::{Price, check_positive_price},
36        quantity::{Quantity, check_positive_quantity},
37    },
38};
39
40/// Represents a perpetual contract instrument (perpetual swap).
41///
42/// Supports perpetuals on any asset class including FX, equities,
43/// commodities, indexes, and cryptocurrencies.
44#[repr(C)]
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[cfg_attr(
47    feature = "python",
48    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
49)]
50#[cfg_attr(
51    feature = "python",
52    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
53)]
54pub struct PerpetualContract {
55    /// The instrument ID for the instrument.
56    pub id: InstrumentId,
57    /// The raw/local/native symbol for the instrument, assigned by the venue.
58    pub raw_symbol: Symbol,
59    /// The underlying asset identifier (e.g., "EURUSD", "NVDA", "GC").
60    pub underlying: Ustr,
61    /// The asset class of the perpetual contract.
62    pub asset_class: AssetClass,
63    /// The base currency (optional, set for FX/crypto underlyings).
64    pub base_currency: Option<Currency>,
65    /// The quote currency.
66    pub quote_currency: Currency,
67    /// The settlement currency.
68    pub settlement_currency: Currency,
69    /// If the instrument costing is inverse (quantity expressed in quote currency units).
70    pub is_inverse: bool,
71    /// The price decimal precision.
72    pub price_precision: u8,
73    /// The trading size decimal precision.
74    pub size_precision: u8,
75    /// The minimum price increment (tick size).
76    pub price_increment: Price,
77    /// The minimum size increment.
78    pub size_increment: Quantity,
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 order notional value.
96    pub max_notional: Option<Money>,
97    /// The minimum allowable order notional value.
98    pub min_notional: Option<Money>,
99    /// The maximum allowable quoted price.
100    pub max_price: Option<Price>,
101    /// The minimum allowable quoted price.
102    pub min_price: Option<Price>,
103    /// The registered variable tick scheme name.
104    pub tick_scheme: Option<Ustr>,
105    /// Additional instrument metadata as a JSON-serializable dictionary.
106    pub info: Option<Params>,
107    /// UNIX timestamp (nanoseconds) when the data event occurred.
108    pub ts_event: UnixNanos,
109    /// UNIX timestamp (nanoseconds) when the data object was initialized.
110    pub ts_init: UnixNanos,
111}
112
113#[bon::bon]
114impl PerpetualContract {
115    /// Creates a new [`PerpetualContract`] instance with correctness checking.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if any input validation fails.
120    ///
121    /// # Notes
122    ///
123    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
124    #[expect(clippy::too_many_arguments)]
125    pub fn new_checked(
126        instrument_id: InstrumentId,
127        raw_symbol: Symbol,
128        underlying: Ustr,
129        asset_class: AssetClass,
130        base_currency: Option<Currency>,
131        quote_currency: Currency,
132        settlement_currency: Currency,
133        is_inverse: bool,
134        price_precision: u8,
135        size_precision: u8,
136        price_increment: Price,
137        size_increment: Quantity,
138        multiplier: Option<Quantity>,
139        lot_size: Option<Quantity>,
140        max_quantity: Option<Quantity>,
141        min_quantity: Option<Quantity>,
142        max_notional: Option<Money>,
143        min_notional: Option<Money>,
144        max_price: Option<Price>,
145        min_price: Option<Price>,
146        margin_init: Option<Decimal>,
147        margin_maint: Option<Decimal>,
148        maker_fee: Option<Decimal>,
149        taker_fee: Option<Decimal>,
150        tick_scheme: Option<Ustr>,
151        info: Option<Params>,
152        ts_event: UnixNanos,
153        ts_init: UnixNanos,
154    ) -> CorrectnessResult<Self> {
155        check_equal_u8(
156            price_precision,
157            price_increment.precision,
158            stringify!(price_precision),
159            stringify!(price_increment.precision),
160        )?;
161        check_equal_u8(
162            size_precision,
163            size_increment.precision,
164            stringify!(size_precision),
165            stringify!(size_increment.precision),
166        )?;
167        check_positive_price(price_increment, stringify!(price_increment))?;
168        check_positive_quantity(size_increment, stringify!(size_increment))?;
169        check_tick_scheme(tick_scheme)?;
170
171        if is_inverse && base_currency.is_none() {
172            return Err(CorrectnessError::PredicateViolation {
173                message: "Inverse perpetual contract requires a `base_currency`".to_string(),
174            });
175        }
176
177        if let Some(multiplier) = multiplier {
178            check_positive_quantity(multiplier, stringify!(multiplier))?;
179        }
180
181        if let Some(lot_size) = lot_size {
182            check_positive_quantity(lot_size, stringify!(lot_size))?;
183        }
184
185        Ok(Self {
186            id: instrument_id,
187            raw_symbol,
188            underlying,
189            asset_class,
190            base_currency,
191            quote_currency,
192            settlement_currency,
193            is_inverse,
194            price_precision,
195            size_precision,
196            price_increment,
197            size_increment,
198            multiplier: multiplier.unwrap_or(Quantity::from(1)),
199            lot_size: lot_size.unwrap_or(Quantity::from(1)),
200            margin_init: margin_init.unwrap_or_default(),
201            margin_maint: margin_maint.unwrap_or_default(),
202            maker_fee: maker_fee.unwrap_or_default(),
203            taker_fee: taker_fee.unwrap_or_default(),
204            max_quantity,
205            min_quantity,
206            max_notional,
207            min_notional,
208            max_price,
209            min_price,
210            tick_scheme,
211            info,
212            ts_event,
213            ts_init,
214        })
215    }
216
217    /// Creates a new [`PerpetualContract`] instance.
218    ///
219    /// # Panics
220    ///
221    /// Panics if any input parameter is invalid (see `new_checked`).
222    #[expect(clippy::too_many_arguments)]
223    #[must_use]
224    pub fn new(
225        instrument_id: InstrumentId,
226        raw_symbol: Symbol,
227        underlying: Ustr,
228        asset_class: AssetClass,
229        base_currency: Option<Currency>,
230        quote_currency: Currency,
231        settlement_currency: Currency,
232        is_inverse: bool,
233        price_precision: u8,
234        size_precision: u8,
235        price_increment: Price,
236        size_increment: Quantity,
237        multiplier: Option<Quantity>,
238        lot_size: Option<Quantity>,
239        max_quantity: Option<Quantity>,
240        min_quantity: Option<Quantity>,
241        max_notional: Option<Money>,
242        min_notional: Option<Money>,
243        max_price: Option<Price>,
244        min_price: Option<Price>,
245        margin_init: Option<Decimal>,
246        margin_maint: Option<Decimal>,
247        maker_fee: Option<Decimal>,
248        taker_fee: Option<Decimal>,
249        tick_scheme: Option<Ustr>,
250        info: Option<Params>,
251        ts_event: UnixNanos,
252        ts_init: UnixNanos,
253    ) -> Self {
254        Self::new_checked(
255            instrument_id,
256            raw_symbol,
257            underlying,
258            asset_class,
259            base_currency,
260            quote_currency,
261            settlement_currency,
262            is_inverse,
263            price_precision,
264            size_precision,
265            price_increment,
266            size_increment,
267            multiplier,
268            lot_size,
269            max_quantity,
270            min_quantity,
271            max_notional,
272            min_notional,
273            max_price,
274            min_price,
275            margin_init,
276            margin_maint,
277            maker_fee,
278            taker_fee,
279            tick_scheme,
280            info,
281            ts_event,
282            ts_init,
283        )
284        .expect_display(FAILED)
285    }
286
287    /// Returns a fluent builder for a [`PerpetualContract`] instance.
288    ///
289    /// Required fields are enforced at compile time; optional fields can be omitted and default
290    /// the same way they do in [`PerpetualContract::new_checked`], which the builder calls so the same
291    /// correctness checks run on `build`.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if any input validation fails (see [`PerpetualContract::new_checked`]).
296    #[builder(start_fn = builder, finish_fn = build)]
297    pub fn build_checked(
298        instrument_id: InstrumentId,
299        raw_symbol: Symbol,
300        underlying: Ustr,
301        asset_class: AssetClass,
302        base_currency: Option<Currency>,
303        quote_currency: Currency,
304        settlement_currency: Currency,
305        is_inverse: bool,
306        price_precision: u8,
307        size_precision: u8,
308        price_increment: Price,
309        size_increment: Quantity,
310        multiplier: Option<Quantity>,
311        lot_size: Option<Quantity>,
312        max_quantity: Option<Quantity>,
313        min_quantity: Option<Quantity>,
314        max_notional: Option<Money>,
315        min_notional: Option<Money>,
316        max_price: Option<Price>,
317        min_price: Option<Price>,
318        margin_init: Option<Decimal>,
319        margin_maint: Option<Decimal>,
320        maker_fee: Option<Decimal>,
321        taker_fee: Option<Decimal>,
322        tick_scheme: Option<Ustr>,
323        info: Option<Params>,
324        ts_event: UnixNanos,
325        ts_init: UnixNanos,
326    ) -> CorrectnessResult<Self> {
327        Self::new_checked(
328            instrument_id,
329            raw_symbol,
330            underlying,
331            asset_class,
332            base_currency,
333            quote_currency,
334            settlement_currency,
335            is_inverse,
336            price_precision,
337            size_precision,
338            price_increment,
339            size_increment,
340            multiplier,
341            lot_size,
342            max_quantity,
343            min_quantity,
344            max_notional,
345            min_notional,
346            max_price,
347            min_price,
348            margin_init,
349            margin_maint,
350            maker_fee,
351            taker_fee,
352            tick_scheme,
353            info,
354            ts_event,
355            ts_init,
356        )
357    }
358}
359
360impl PartialEq<Self> for PerpetualContract {
361    fn eq(&self, other: &Self) -> bool {
362        self.id == other.id
363    }
364}
365
366impl Eq for PerpetualContract {}
367
368impl Hash for PerpetualContract {
369    fn hash<H: Hasher>(&self, state: &mut H) {
370        self.id.hash(state);
371    }
372}
373
374impl Instrument for PerpetualContract {
375    fn tick_scheme(&self) -> Option<Ustr> {
376        self.tick_scheme
377    }
378    fn into_any(self) -> InstrumentAny {
379        InstrumentAny::PerpetualContract(self)
380    }
381
382    fn id(&self) -> InstrumentId {
383        self.id
384    }
385
386    fn raw_symbol(&self) -> Symbol {
387        self.raw_symbol
388    }
389
390    fn asset_class(&self) -> AssetClass {
391        self.asset_class
392    }
393
394    fn instrument_class(&self) -> InstrumentClass {
395        InstrumentClass::Swap
396    }
397
398    fn underlying(&self) -> Option<Ustr> {
399        Some(self.underlying)
400    }
401
402    fn base_currency(&self) -> Option<Currency> {
403        self.base_currency
404    }
405
406    fn quote_currency(&self) -> Currency {
407        self.quote_currency
408    }
409
410    fn settlement_currency(&self) -> Currency {
411        self.settlement_currency
412    }
413
414    fn isin(&self) -> Option<Ustr> {
415        None
416    }
417
418    fn option_kind(&self) -> Option<OptionKind> {
419        None
420    }
421
422    fn exchange(&self) -> Option<Ustr> {
423        None
424    }
425
426    fn strike_price(&self) -> Option<Price> {
427        None
428    }
429
430    fn activation_ns(&self) -> Option<UnixNanos> {
431        None
432    }
433
434    fn expiration_ns(&self) -> Option<UnixNanos> {
435        None
436    }
437
438    fn is_inverse(&self) -> bool {
439        self.is_inverse
440    }
441
442    fn price_precision(&self) -> u8 {
443        self.price_precision
444    }
445
446    fn size_precision(&self) -> u8 {
447        self.size_precision
448    }
449
450    fn price_increment(&self) -> Price {
451        self.price_increment
452    }
453
454    fn size_increment(&self) -> Quantity {
455        self.size_increment
456    }
457
458    fn multiplier(&self) -> Quantity {
459        self.multiplier
460    }
461
462    fn lot_size(&self) -> Option<Quantity> {
463        Some(self.lot_size)
464    }
465
466    fn max_quantity(&self) -> Option<Quantity> {
467        self.max_quantity
468    }
469
470    fn min_quantity(&self) -> Option<Quantity> {
471        self.min_quantity
472    }
473
474    fn max_notional(&self) -> Option<Money> {
475        self.max_notional
476    }
477
478    fn min_notional(&self) -> Option<Money> {
479        self.min_notional
480    }
481
482    fn max_price(&self) -> Option<Price> {
483        self.max_price
484    }
485
486    fn min_price(&self) -> Option<Price> {
487        self.min_price
488    }
489
490    fn margin_init(&self) -> Decimal {
491        self.margin_init
492    }
493
494    fn margin_maint(&self) -> Decimal {
495        self.margin_maint
496    }
497
498    fn maker_fee(&self) -> Decimal {
499        self.maker_fee
500    }
501
502    fn taker_fee(&self) -> Decimal {
503        self.taker_fee
504    }
505
506    fn ts_event(&self) -> UnixNanos {
507        self.ts_event
508    }
509
510    fn ts_init(&self) -> UnixNanos {
511        self.ts_init
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use rstest::rstest;
518    use rust_decimal_macros::dec;
519    use ustr::Ustr;
520
521    use crate::{
522        enums::{AssetClass, InstrumentClass},
523        identifiers::{InstrumentId, Symbol},
524        instruments::{Instrument, PerpetualContract, stubs::*},
525        types::{Currency, Money, Price, Quantity},
526    };
527
528    #[rstest]
529    fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
530        assert_eq!(
531            perpetual_contract_eurusd.id(),
532            InstrumentId::from("EURUSD-PERP.AX"),
533        );
534        assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
535        assert_eq!(
536            perpetual_contract_eurusd.instrument_class(),
537            InstrumentClass::Swap
538        );
539        assert_eq!(
540            perpetual_contract_eurusd.base_currency(),
541            Some(Currency::EUR())
542        );
543        assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
544        assert_eq!(
545            perpetual_contract_eurusd.settlement_currency(),
546            Currency::USD()
547        );
548        assert!(!perpetual_contract_eurusd.is_inverse());
549        assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
550        assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
551        assert_eq!(
552            perpetual_contract_eurusd.price_increment(),
553            Price::from("0.00001")
554        );
555        assert_eq!(
556            perpetual_contract_eurusd.size_increment(),
557            Quantity::from("1")
558        );
559        assert_eq!(
560            perpetual_contract_eurusd.underlying(),
561            Some(Ustr::from("EURUSD")),
562        );
563    }
564
565    #[rstest]
566    fn test_new_checked_inverse_without_base_currency() {
567        let result = PerpetualContract::new_checked(
568            InstrumentId::from("TEST.EXCHANGE"),
569            Symbol::from("TEST"),
570            Ustr::from("TEST"),
571            AssetClass::FX,
572            None, // no base_currency
573            Currency::USD(),
574            Currency::USD(),
575            true, // is_inverse
576            5,
577            0,
578            Price::from("0.00001"),
579            Quantity::from("1"),
580            None,
581            None,
582            None,
583            None,
584            None,
585            None,
586            None,
587            None,
588            None,
589            None,
590            None,
591            None,
592            None,
593            None,
594            0.into(),
595            0.into(),
596        );
597        assert!(result.is_err());
598        assert!(result.unwrap_err().to_string().contains("base_currency"),);
599    }
600
601    #[rstest]
602    fn test_new_checked_price_precision_mismatch() {
603        let result = PerpetualContract::new_checked(
604            InstrumentId::from("TEST.EXCHANGE"),
605            Symbol::from("TEST"),
606            Ustr::from("TEST"),
607            AssetClass::FX,
608            Some(Currency::EUR()),
609            Currency::USD(),
610            Currency::USD(),
611            false,
612            3, // mismatch
613            0,
614            Price::from("0.00001"),
615            Quantity::from("1"),
616            None,
617            None,
618            None,
619            None,
620            None,
621            None,
622            None,
623            None,
624            None,
625            None,
626            None,
627            None,
628            None,
629            None,
630            0.into(),
631            0.into(),
632        );
633        assert!(result.is_err());
634    }
635
636    #[rstest]
637    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
638    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
639    fn test_new_checked_rejects_non_positive_sizing(
640        #[case] multiplier: Option<Quantity>,
641        #[case] lot_size: Option<Quantity>,
642    ) {
643        let result = PerpetualContract::new_checked(
644            InstrumentId::from("TEST.EXCHANGE"),
645            Symbol::from("TEST"),
646            Ustr::from("TEST"),
647            AssetClass::FX,
648            Some(Currency::EUR()),
649            Currency::USD(),
650            Currency::USD(),
651            false,
652            5,
653            0,
654            Price::from("0.00001"),
655            Quantity::from("1"),
656            multiplier,
657            lot_size,
658            None,
659            None,
660            None,
661            None,
662            None,
663            None,
664            None,
665            None,
666            None,
667            None,
668            None,
669            None,
670            0.into(),
671            0.into(),
672        );
673        let error = result.unwrap_err();
674        assert!(error.to_string().contains("not positive"), "{error}");
675    }
676
677    #[rstest]
678    fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
679        let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
680        let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
681        assert_eq!(perpetual_contract_eurusd, deserialized);
682    }
683
684    #[rstest]
685    fn test_builder_matches_new_checked() {
686        let positional = PerpetualContract::new_checked(
687            InstrumentId::from("EURUSD-PERP.AX"),
688            Symbol::from("EURUSD-PERP"),
689            Ustr::from("EURUSD"),
690            AssetClass::FX,
691            Some(Currency::EUR()),
692            Currency::USD(),
693            Currency::BTC(),
694            false,
695            5,
696            0,
697            Price::from("0.00001"),
698            Quantity::from("1"),
699            Some(Quantity::from("10")),
700            Some(Quantity::from("5")),
701            Some(Quantity::from("100")),
702            Some(Quantity::from("1")),
703            Some(Money::new(1000.0, Currency::USD())),
704            Some(Money::new(10.0, Currency::USD())),
705            Some(Price::from("9.99999")),
706            Some(Price::from("0.00002")),
707            Some(dec!(0.01)),
708            Some(dec!(0.02)),
709            Some(dec!(0.0002)),
710            Some(dec!(0.0004)),
711            None,
712            None,
713            1.into(),
714            2.into(),
715        )
716        .unwrap();
717
718        let built = PerpetualContract::builder()
719            .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
720            .raw_symbol(Symbol::from("EURUSD-PERP"))
721            .underlying(Ustr::from("EURUSD"))
722            .asset_class(AssetClass::FX)
723            .base_currency(Currency::EUR())
724            .quote_currency(Currency::USD())
725            .settlement_currency(Currency::BTC())
726            .is_inverse(false)
727            .price_precision(5)
728            .size_precision(0)
729            .price_increment(Price::from("0.00001"))
730            .size_increment(Quantity::from("1"))
731            .multiplier(Quantity::from("10"))
732            .lot_size(Quantity::from("5"))
733            .max_quantity(Quantity::from("100"))
734            .min_quantity(Quantity::from("1"))
735            .max_notional(Money::new(1000.0, Currency::USD()))
736            .min_notional(Money::new(10.0, Currency::USD()))
737            .max_price(Price::from("9.99999"))
738            .min_price(Price::from("0.00002"))
739            .margin_init(dec!(0.01))
740            .margin_maint(dec!(0.02))
741            .maker_fee(dec!(0.0002))
742            .taker_fee(dec!(0.0004))
743            .ts_event(1.into())
744            .ts_init(2.into())
745            .build()
746            .unwrap();
747
748        assert_eq!(
749            serde_json::to_value(&positional).unwrap(),
750            serde_json::to_value(&built).unwrap(),
751        );
752    }
753}