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 into_any(self) -> InstrumentAny {
294        InstrumentAny::PerpetualContract(self)
295    }
296
297    fn id(&self) -> InstrumentId {
298        self.id
299    }
300
301    fn raw_symbol(&self) -> Symbol {
302        self.raw_symbol
303    }
304
305    fn asset_class(&self) -> AssetClass {
306        self.asset_class
307    }
308
309    fn instrument_class(&self) -> InstrumentClass {
310        InstrumentClass::Swap
311    }
312
313    fn underlying(&self) -> Option<Ustr> {
314        Some(self.underlying)
315    }
316
317    fn base_currency(&self) -> Option<Currency> {
318        self.base_currency
319    }
320
321    fn quote_currency(&self) -> Currency {
322        self.quote_currency
323    }
324
325    fn settlement_currency(&self) -> Currency {
326        self.settlement_currency
327    }
328
329    fn isin(&self) -> Option<Ustr> {
330        None
331    }
332
333    fn option_kind(&self) -> Option<OptionKind> {
334        None
335    }
336
337    fn exchange(&self) -> Option<Ustr> {
338        None
339    }
340
341    fn strike_price(&self) -> Option<Price> {
342        None
343    }
344
345    fn activation_ns(&self) -> Option<UnixNanos> {
346        None
347    }
348
349    fn expiration_ns(&self) -> Option<UnixNanos> {
350        None
351    }
352
353    fn is_inverse(&self) -> bool {
354        self.is_inverse
355    }
356
357    fn price_precision(&self) -> u8 {
358        self.price_precision
359    }
360
361    fn size_precision(&self) -> u8 {
362        self.size_precision
363    }
364
365    fn price_increment(&self) -> Price {
366        self.price_increment
367    }
368
369    fn size_increment(&self) -> Quantity {
370        self.size_increment
371    }
372
373    fn multiplier(&self) -> Quantity {
374        self.multiplier
375    }
376
377    fn lot_size(&self) -> Option<Quantity> {
378        Some(self.lot_size)
379    }
380
381    fn max_quantity(&self) -> Option<Quantity> {
382        self.max_quantity
383    }
384
385    fn min_quantity(&self) -> Option<Quantity> {
386        self.min_quantity
387    }
388
389    fn max_notional(&self) -> Option<Money> {
390        self.max_notional
391    }
392
393    fn min_notional(&self) -> Option<Money> {
394        self.min_notional
395    }
396
397    fn max_price(&self) -> Option<Price> {
398        self.max_price
399    }
400
401    fn min_price(&self) -> Option<Price> {
402        self.min_price
403    }
404
405    fn margin_init(&self) -> Decimal {
406        self.margin_init
407    }
408
409    fn margin_maint(&self) -> Decimal {
410        self.margin_maint
411    }
412
413    fn maker_fee(&self) -> Decimal {
414        self.maker_fee
415    }
416
417    fn taker_fee(&self) -> Decimal {
418        self.taker_fee
419    }
420
421    fn tick_scheme(&self) -> Option<Ustr> {
422        self.tick_scheme
423    }
424
425    fn info(&self) -> Option<&Params> {
426        self.info.as_ref()
427    }
428
429    fn ts_event(&self) -> UnixNanos {
430        self.ts_event
431    }
432
433    fn ts_init(&self) -> UnixNanos {
434        self.ts_init
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use rstest::rstest;
441    use rust_decimal_macros::dec;
442    use ustr::Ustr;
443
444    use crate::{
445        enums::{AssetClass, InstrumentClass},
446        identifiers::{InstrumentId, Symbol},
447        instruments::{Instrument, PerpetualContract, stubs::*},
448        types::{Currency, Money, Price, Quantity},
449    };
450
451    #[rstest]
452    fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
453        assert_eq!(
454            perpetual_contract_eurusd.id(),
455            InstrumentId::from("EURUSD-PERP.AX"),
456        );
457        assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
458        assert_eq!(
459            perpetual_contract_eurusd.instrument_class(),
460            InstrumentClass::Swap
461        );
462        assert_eq!(
463            perpetual_contract_eurusd.base_currency(),
464            Some(Currency::EUR())
465        );
466        assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
467        assert_eq!(
468            perpetual_contract_eurusd.settlement_currency(),
469            Currency::USD()
470        );
471        assert!(!perpetual_contract_eurusd.is_inverse());
472        assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
473        assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
474        assert_eq!(
475            perpetual_contract_eurusd.price_increment(),
476            Price::from("0.00001")
477        );
478        assert_eq!(
479            perpetual_contract_eurusd.size_increment(),
480            Quantity::from("1")
481        );
482        assert_eq!(
483            perpetual_contract_eurusd.underlying(),
484            Some(Ustr::from("EURUSD")),
485        );
486    }
487
488    #[rstest]
489    fn test_new_checked_inverse_without_base_currency() {
490        let result = PerpetualContract::new_checked(
491            InstrumentId::from("TEST.EXCHANGE"),
492            Symbol::from("TEST"),
493            Ustr::from("TEST"),
494            AssetClass::FX,
495            None, // no base_currency
496            Currency::USD(),
497            Currency::USD(),
498            true, // is_inverse
499            5,
500            0,
501            Price::from("0.00001"),
502            Quantity::from("1"),
503            None,
504            None,
505            None,
506            None,
507            None,
508            None,
509            None,
510            None,
511            None,
512            None,
513            None,
514            None,
515            None,
516            None,
517            0.into(),
518            0.into(),
519        );
520        assert!(result.is_err());
521        assert!(result.unwrap_err().to_string().contains("base_currency"),);
522    }
523
524    #[rstest]
525    fn test_new_checked_price_precision_mismatch() {
526        let result = PerpetualContract::new_checked(
527            InstrumentId::from("TEST.EXCHANGE"),
528            Symbol::from("TEST"),
529            Ustr::from("TEST"),
530            AssetClass::FX,
531            Some(Currency::EUR()),
532            Currency::USD(),
533            Currency::USD(),
534            false,
535            3, // mismatch
536            0,
537            Price::from("0.00001"),
538            Quantity::from("1"),
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            None,
547            None,
548            None,
549            None,
550            None,
551            None,
552            None,
553            0.into(),
554            0.into(),
555        );
556        assert!(result.is_err());
557    }
558
559    #[rstest]
560    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
561    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
562    fn test_new_checked_rejects_non_positive_sizing(
563        #[case] multiplier: Option<Quantity>,
564        #[case] lot_size: Option<Quantity>,
565    ) {
566        let result = PerpetualContract::new_checked(
567            InstrumentId::from("TEST.EXCHANGE"),
568            Symbol::from("TEST"),
569            Ustr::from("TEST"),
570            AssetClass::FX,
571            Some(Currency::EUR()),
572            Currency::USD(),
573            Currency::USD(),
574            false,
575            5,
576            0,
577            Price::from("0.00001"),
578            Quantity::from("1"),
579            multiplier,
580            lot_size,
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            0.into(),
594            0.into(),
595        );
596        let error = result.unwrap_err();
597        assert!(error.to_string().contains("not positive"), "{error}");
598    }
599
600    #[rstest]
601    fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
602        let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
603        let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
604        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
605    }
606
607    #[rstest]
608    fn test_builder_matches_new_checked() {
609        let positional = PerpetualContract::new_checked(
610            InstrumentId::from("EURUSD-PERP.AX"),
611            Symbol::from("EURUSD-PERP"),
612            Ustr::from("EURUSD"),
613            AssetClass::FX,
614            Some(Currency::EUR()),
615            Currency::USD(),
616            Currency::BTC(),
617            false,
618            5,
619            0,
620            Price::from("0.00001"),
621            Quantity::from("1"),
622            Some(Quantity::from("10")),
623            Some(Quantity::from("5")),
624            Some(Quantity::from("100")),
625            Some(Quantity::from("1")),
626            Some(Money::new(1000.0, Currency::USD())),
627            Some(Money::new(10.0, Currency::USD())),
628            Some(Price::from("9.99999")),
629            Some(Price::from("0.00002")),
630            Some(dec!(0.01)),
631            Some(dec!(0.02)),
632            Some(dec!(0.0002)),
633            Some(dec!(0.0004)),
634            None,
635            None,
636            1.into(),
637            2.into(),
638        )
639        .unwrap();
640
641        let built = PerpetualContract::builder()
642            .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
643            .raw_symbol(Symbol::from("EURUSD-PERP"))
644            .underlying(Ustr::from("EURUSD"))
645            .asset_class(AssetClass::FX)
646            .base_currency(Currency::EUR())
647            .quote_currency(Currency::USD())
648            .settlement_currency(Currency::BTC())
649            .is_inverse(false)
650            .price_precision(5)
651            .size_precision(0)
652            .price_increment(Price::from("0.00001"))
653            .size_increment(Quantity::from("1"))
654            .multiplier(Quantity::from("10"))
655            .lot_size(Quantity::from("5"))
656            .max_quantity(Quantity::from("100"))
657            .min_quantity(Quantity::from("1"))
658            .max_notional(Money::new(1000.0, Currency::USD()))
659            .min_notional(Money::new(10.0, Currency::USD()))
660            .max_price(Price::from("9.99999"))
661            .min_price(Price::from("0.00002"))
662            .margin_init(dec!(0.01))
663            .margin_maint(dec!(0.02))
664            .maker_fee(dec!(0.0002))
665            .taker_fee(dec!(0.0004))
666            .ts_event(1.into())
667            .ts_init(2.into())
668            .build()
669            .unwrap();
670
671        assert_eq!(
672            serde_json::to_value(&positional).unwrap(),
673            serde_json::to_value(&built).unwrap(),
674        );
675    }
676}