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