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};
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    /// 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
111impl PerpetualContract {
112    /// Creates a new [`PerpetualContract`] instance with correctness checking.
113    ///
114    /// # Notes
115    ///
116    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
117    /// # Errors
118    ///
119    /// Returns an error if any input validation fails.
120    #[expect(clippy::too_many_arguments)]
121    pub fn new_checked(
122        instrument_id: InstrumentId,
123        raw_symbol: Symbol,
124        underlying: Ustr,
125        asset_class: AssetClass,
126        base_currency: Option<Currency>,
127        quote_currency: Currency,
128        settlement_currency: Currency,
129        is_inverse: bool,
130        price_precision: u8,
131        size_precision: u8,
132        price_increment: Price,
133        size_increment: Quantity,
134        multiplier: Option<Quantity>,
135        lot_size: Option<Quantity>,
136        max_quantity: Option<Quantity>,
137        min_quantity: Option<Quantity>,
138        max_notional: Option<Money>,
139        min_notional: Option<Money>,
140        max_price: Option<Price>,
141        min_price: Option<Price>,
142        margin_init: Option<Decimal>,
143        margin_maint: Option<Decimal>,
144        maker_fee: Option<Decimal>,
145        taker_fee: Option<Decimal>,
146        info: Option<Params>,
147        ts_event: UnixNanos,
148        ts_init: UnixNanos,
149    ) -> CorrectnessResult<Self> {
150        check_equal_u8(
151            price_precision,
152            price_increment.precision,
153            stringify!(price_precision),
154            stringify!(price_increment.precision),
155        )?;
156        check_equal_u8(
157            size_precision,
158            size_increment.precision,
159            stringify!(size_precision),
160            stringify!(size_increment.precision),
161        )?;
162        check_positive_price(price_increment, stringify!(price_increment))?;
163        check_positive_quantity(size_increment, stringify!(size_increment))?;
164
165        if is_inverse && base_currency.is_none() {
166            return Err(CorrectnessError::PredicateViolation {
167                message: "Inverse perpetual contract requires a `base_currency`".to_string(),
168            });
169        }
170
171        Ok(Self {
172            id: instrument_id,
173            raw_symbol,
174            underlying,
175            asset_class,
176            base_currency,
177            quote_currency,
178            settlement_currency,
179            is_inverse,
180            price_precision,
181            size_precision,
182            price_increment,
183            size_increment,
184            multiplier: multiplier.unwrap_or(Quantity::from(1)),
185            lot_size: lot_size.unwrap_or(Quantity::from(1)),
186            margin_init: margin_init.unwrap_or_default(),
187            margin_maint: margin_maint.unwrap_or_default(),
188            maker_fee: maker_fee.unwrap_or_default(),
189            taker_fee: taker_fee.unwrap_or_default(),
190            max_quantity,
191            min_quantity,
192            max_notional,
193            min_notional,
194            max_price,
195            min_price,
196            info,
197            ts_event,
198            ts_init,
199        })
200    }
201
202    /// Creates a new [`PerpetualContract`] instance.
203    ///
204    /// # Panics
205    ///
206    /// Panics if any input parameter is invalid (see `new_checked`).
207    #[expect(clippy::too_many_arguments)]
208    #[must_use]
209    pub fn new(
210        instrument_id: InstrumentId,
211        raw_symbol: Symbol,
212        underlying: Ustr,
213        asset_class: AssetClass,
214        base_currency: Option<Currency>,
215        quote_currency: Currency,
216        settlement_currency: Currency,
217        is_inverse: bool,
218        price_precision: u8,
219        size_precision: u8,
220        price_increment: Price,
221        size_increment: Quantity,
222        multiplier: Option<Quantity>,
223        lot_size: Option<Quantity>,
224        max_quantity: Option<Quantity>,
225        min_quantity: Option<Quantity>,
226        max_notional: Option<Money>,
227        min_notional: Option<Money>,
228        max_price: Option<Price>,
229        min_price: Option<Price>,
230        margin_init: Option<Decimal>,
231        margin_maint: Option<Decimal>,
232        maker_fee: Option<Decimal>,
233        taker_fee: Option<Decimal>,
234        info: Option<Params>,
235        ts_event: UnixNanos,
236        ts_init: UnixNanos,
237    ) -> Self {
238        Self::new_checked(
239            instrument_id,
240            raw_symbol,
241            underlying,
242            asset_class,
243            base_currency,
244            quote_currency,
245            settlement_currency,
246            is_inverse,
247            price_precision,
248            size_precision,
249            price_increment,
250            size_increment,
251            multiplier,
252            lot_size,
253            max_quantity,
254            min_quantity,
255            max_notional,
256            min_notional,
257            max_price,
258            min_price,
259            margin_init,
260            margin_maint,
261            maker_fee,
262            taker_fee,
263            info,
264            ts_event,
265            ts_init,
266        )
267        .expect_display(FAILED)
268    }
269}
270
271impl PartialEq<Self> for PerpetualContract {
272    fn eq(&self, other: &Self) -> bool {
273        self.id == other.id
274    }
275}
276
277impl Eq for PerpetualContract {}
278
279impl Hash for PerpetualContract {
280    fn hash<H: Hasher>(&self, state: &mut H) {
281        self.id.hash(state);
282    }
283}
284
285impl Instrument for PerpetualContract {
286    fn into_any(self) -> InstrumentAny {
287        InstrumentAny::PerpetualContract(self)
288    }
289
290    fn id(&self) -> InstrumentId {
291        self.id
292    }
293
294    fn raw_symbol(&self) -> Symbol {
295        self.raw_symbol
296    }
297
298    fn asset_class(&self) -> AssetClass {
299        self.asset_class
300    }
301
302    fn instrument_class(&self) -> InstrumentClass {
303        InstrumentClass::Swap
304    }
305
306    fn underlying(&self) -> Option<Ustr> {
307        Some(self.underlying)
308    }
309
310    fn base_currency(&self) -> Option<Currency> {
311        self.base_currency
312    }
313
314    fn quote_currency(&self) -> Currency {
315        self.quote_currency
316    }
317
318    fn settlement_currency(&self) -> Currency {
319        self.settlement_currency
320    }
321
322    fn isin(&self) -> Option<Ustr> {
323        None
324    }
325
326    fn option_kind(&self) -> Option<OptionKind> {
327        None
328    }
329
330    fn exchange(&self) -> Option<Ustr> {
331        None
332    }
333
334    fn strike_price(&self) -> Option<Price> {
335        None
336    }
337
338    fn activation_ns(&self) -> Option<UnixNanos> {
339        None
340    }
341
342    fn expiration_ns(&self) -> Option<UnixNanos> {
343        None
344    }
345
346    fn is_inverse(&self) -> bool {
347        self.is_inverse
348    }
349
350    fn price_precision(&self) -> u8 {
351        self.price_precision
352    }
353
354    fn size_precision(&self) -> u8 {
355        self.size_precision
356    }
357
358    fn price_increment(&self) -> Price {
359        self.price_increment
360    }
361
362    fn size_increment(&self) -> Quantity {
363        self.size_increment
364    }
365
366    fn multiplier(&self) -> Quantity {
367        self.multiplier
368    }
369
370    fn lot_size(&self) -> Option<Quantity> {
371        Some(self.lot_size)
372    }
373
374    fn max_quantity(&self) -> Option<Quantity> {
375        self.max_quantity
376    }
377
378    fn min_quantity(&self) -> Option<Quantity> {
379        self.min_quantity
380    }
381
382    fn max_notional(&self) -> Option<Money> {
383        self.max_notional
384    }
385
386    fn min_notional(&self) -> Option<Money> {
387        self.min_notional
388    }
389
390    fn max_price(&self) -> Option<Price> {
391        self.max_price
392    }
393
394    fn min_price(&self) -> Option<Price> {
395        self.min_price
396    }
397
398    fn margin_init(&self) -> Decimal {
399        self.margin_init
400    }
401
402    fn margin_maint(&self) -> Decimal {
403        self.margin_maint
404    }
405
406    fn maker_fee(&self) -> Decimal {
407        self.maker_fee
408    }
409
410    fn taker_fee(&self) -> Decimal {
411        self.taker_fee
412    }
413
414    fn ts_event(&self) -> UnixNanos {
415        self.ts_event
416    }
417
418    fn ts_init(&self) -> UnixNanos {
419        self.ts_init
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use rstest::rstest;
426    use ustr::Ustr;
427
428    use crate::{
429        enums::{AssetClass, InstrumentClass},
430        identifiers::{InstrumentId, Symbol},
431        instruments::{Instrument, PerpetualContract, stubs::*},
432        types::{Currency, Price, Quantity},
433    };
434
435    #[rstest]
436    fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
437        assert_eq!(
438            perpetual_contract_eurusd.id(),
439            InstrumentId::from("EURUSD-PERP.AX"),
440        );
441        assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
442        assert_eq!(
443            perpetual_contract_eurusd.instrument_class(),
444            InstrumentClass::Swap
445        );
446        assert_eq!(
447            perpetual_contract_eurusd.base_currency(),
448            Some(Currency::EUR())
449        );
450        assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
451        assert_eq!(
452            perpetual_contract_eurusd.settlement_currency(),
453            Currency::USD()
454        );
455        assert!(!perpetual_contract_eurusd.is_inverse());
456        assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
457        assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
458        assert_eq!(
459            perpetual_contract_eurusd.price_increment(),
460            Price::from("0.00001")
461        );
462        assert_eq!(
463            perpetual_contract_eurusd.size_increment(),
464            Quantity::from("1")
465        );
466        assert_eq!(
467            perpetual_contract_eurusd.underlying(),
468            Some(Ustr::from("EURUSD")),
469        );
470    }
471
472    #[rstest]
473    fn test_new_checked_inverse_without_base_currency() {
474        let result = PerpetualContract::new_checked(
475            InstrumentId::from("TEST.EXCHANGE"),
476            Symbol::from("TEST"),
477            Ustr::from("TEST"),
478            AssetClass::FX,
479            None, // no base_currency
480            Currency::USD(),
481            Currency::USD(),
482            true, // is_inverse
483            5,
484            0,
485            Price::from("0.00001"),
486            Quantity::from("1"),
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            None,
494            None,
495            None,
496            None,
497            None,
498            None,
499            None,
500            0.into(),
501            0.into(),
502        );
503        assert!(result.is_err());
504        assert!(result.unwrap_err().to_string().contains("base_currency"),);
505    }
506
507    #[rstest]
508    fn test_new_checked_price_precision_mismatch() {
509        let result = PerpetualContract::new_checked(
510            InstrumentId::from("TEST.EXCHANGE"),
511            Symbol::from("TEST"),
512            Ustr::from("TEST"),
513            AssetClass::FX,
514            Some(Currency::EUR()),
515            Currency::USD(),
516            Currency::USD(),
517            false,
518            3, // mismatch
519            0,
520            Price::from("0.00001"),
521            Quantity::from("1"),
522            None,
523            None,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            0.into(),
536            0.into(),
537        );
538        assert!(result.is_err());
539    }
540
541    #[rstest]
542    fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
543        let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
544        let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
545        assert_eq!(perpetual_contract_eurusd, deserialized);
546    }
547}