Skip to main content

nautilus_model/instruments/
crypto_perpetual.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::{CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::any::InstrumentAny;
27use crate::{
28    enums::{AssetClass, InstrumentClass, OptionKind},
29    identifiers::{InstrumentId, Symbol},
30    instruments::Instrument,
31    types::{
32        currency::Currency,
33        money::Money,
34        price::{Price, check_positive_price},
35        quantity::{Quantity, check_positive_quantity},
36    },
37};
38
39/// Represents a crypto perpetual futures contract instrument (a.k.a. perpetual swap).
40#[repr(C)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
45)]
46#[cfg_attr(
47    feature = "python",
48    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct CryptoPerpetual {
51    /// The instrument ID for the instrument.
52    pub id: InstrumentId,
53    /// The raw/local/native symbol for the instrument, assigned by the venue.
54    pub raw_symbol: Symbol,
55    /// The base currency.
56    pub base_currency: Currency,
57    /// The quote currency.
58    pub quote_currency: Currency,
59    /// The settlement currency.
60    pub settlement_currency: Currency,
61    /// If the instrument costing is inverse (quantity expressed in quote currency units).
62    pub is_inverse: bool,
63    /// The price decimal precision.
64    pub price_precision: u8,
65    /// The trading size decimal precision.
66    pub size_precision: u8,
67    /// The minimum price increment (tick size).
68    pub price_increment: Price,
69    /// The minimum size increment.
70    pub size_increment: Quantity,
71    /// The contract multiplier.
72    pub multiplier: Quantity,
73    /// The rounded lot unit size (standard/board).
74    pub lot_size: Quantity,
75    /// The initial (order) margin requirement in percentage of order value.
76    pub margin_init: Decimal,
77    /// The maintenance (position) margin in percentage of position value.
78    pub margin_maint: Decimal,
79    /// The fee rate for liquidity makers as a percentage of order value.
80    pub maker_fee: Decimal,
81    /// The fee rate for liquidity takers as a percentage of order value.
82    pub taker_fee: Decimal,
83    /// The maximum allowable order quantity.
84    pub max_quantity: Option<Quantity>,
85    /// The minimum allowable order quantity.
86    pub min_quantity: Option<Quantity>,
87    /// The maximum allowable order notional value.
88    pub max_notional: Option<Money>,
89    /// The minimum allowable order notional value.
90    pub min_notional: Option<Money>,
91    /// The maximum allowable quoted price.
92    pub max_price: Option<Price>,
93    /// The minimum allowable quoted price.
94    pub min_price: Option<Price>,
95    /// Additional instrument metadata as a JSON-serializable dictionary.
96    pub info: Option<Params>,
97    /// UNIX timestamp (nanoseconds) when the data event occurred.
98    pub ts_event: UnixNanos,
99    /// UNIX timestamp (nanoseconds) when the data object was initialized.
100    pub ts_init: UnixNanos,
101}
102
103impl CryptoPerpetual {
104    /// Creates a new [`CryptoPerpetual`] instance with correctness checking.
105    ///
106    /// # Notes
107    ///
108    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
109    /// # Errors
110    ///
111    /// Returns an error if any input validation fails.
112    #[expect(clippy::too_many_arguments)]
113    pub fn new_checked(
114        instrument_id: InstrumentId,
115        raw_symbol: Symbol,
116        base_currency: Currency,
117        quote_currency: Currency,
118        settlement_currency: Currency,
119        is_inverse: bool,
120        price_precision: u8,
121        size_precision: u8,
122        price_increment: Price,
123        size_increment: Quantity,
124        multiplier: Option<Quantity>,
125        lot_size: Option<Quantity>,
126        max_quantity: Option<Quantity>,
127        min_quantity: Option<Quantity>,
128        max_notional: Option<Money>,
129        min_notional: Option<Money>,
130        max_price: Option<Price>,
131        min_price: Option<Price>,
132        margin_init: Option<Decimal>,
133        margin_maint: Option<Decimal>,
134        maker_fee: Option<Decimal>,
135        taker_fee: Option<Decimal>,
136        info: Option<Params>,
137        ts_event: UnixNanos,
138        ts_init: UnixNanos,
139    ) -> CorrectnessResult<Self> {
140        check_equal_u8(
141            price_precision,
142            price_increment.precision,
143            stringify!(price_precision),
144            stringify!(price_increment.precision),
145        )?;
146        check_equal_u8(
147            size_precision,
148            size_increment.precision,
149            stringify!(size_precision),
150            stringify!(size_increment.precision),
151        )?;
152        check_positive_price(price_increment, stringify!(price_increment))?;
153        check_positive_quantity(size_increment, stringify!(size_increment))?;
154
155        Ok(Self {
156            id: instrument_id,
157            raw_symbol,
158            base_currency,
159            quote_currency,
160            settlement_currency,
161            is_inverse,
162            price_precision,
163            size_precision,
164            price_increment,
165            size_increment,
166            multiplier: multiplier.unwrap_or(Quantity::from(1)),
167            lot_size: lot_size.unwrap_or(Quantity::from(1)),
168            margin_init: margin_init.unwrap_or_default(),
169            margin_maint: margin_maint.unwrap_or_default(),
170            maker_fee: maker_fee.unwrap_or_default(),
171            taker_fee: taker_fee.unwrap_or_default(),
172            max_quantity,
173            min_quantity,
174            max_notional,
175            min_notional,
176            max_price,
177            min_price,
178            info,
179            ts_event,
180            ts_init,
181        })
182    }
183
184    /// Creates a new [`CryptoPerpetual`] instance.
185    ///
186    /// # Panics
187    ///
188    /// Panics if any input parameter is invalid (see `new_checked`).
189    #[expect(clippy::too_many_arguments)]
190    #[must_use]
191    pub fn new(
192        instrument_id: InstrumentId,
193        raw_symbol: Symbol,
194        base_currency: Currency,
195        quote_currency: Currency,
196        settlement_currency: Currency,
197        is_inverse: bool,
198        price_precision: u8,
199        size_precision: u8,
200        price_increment: Price,
201        size_increment: Quantity,
202        multiplier: Option<Quantity>,
203        lot_size: Option<Quantity>,
204        max_quantity: Option<Quantity>,
205        min_quantity: Option<Quantity>,
206        max_notional: Option<Money>,
207        min_notional: Option<Money>,
208        max_price: Option<Price>,
209        min_price: Option<Price>,
210        margin_init: Option<Decimal>,
211        margin_maint: Option<Decimal>,
212        maker_fee: Option<Decimal>,
213        taker_fee: Option<Decimal>,
214        info: Option<Params>,
215        ts_event: UnixNanos,
216        ts_init: UnixNanos,
217    ) -> Self {
218        Self::new_checked(
219            instrument_id,
220            raw_symbol,
221            base_currency,
222            quote_currency,
223            settlement_currency,
224            is_inverse,
225            price_precision,
226            size_precision,
227            price_increment,
228            size_increment,
229            multiplier,
230            lot_size,
231            max_quantity,
232            min_quantity,
233            max_notional,
234            min_notional,
235            max_price,
236            min_price,
237            margin_init,
238            margin_maint,
239            maker_fee,
240            taker_fee,
241            info,
242            ts_event,
243            ts_init,
244        )
245        .expect_display(FAILED)
246    }
247}
248
249impl PartialEq<Self> for CryptoPerpetual {
250    fn eq(&self, other: &Self) -> bool {
251        self.id == other.id
252    }
253}
254
255impl Eq for CryptoPerpetual {}
256
257impl Hash for CryptoPerpetual {
258    fn hash<H: Hasher>(&self, state: &mut H) {
259        self.id.hash(state);
260    }
261}
262
263impl Instrument for CryptoPerpetual {
264    fn into_any(self) -> InstrumentAny {
265        InstrumentAny::CryptoPerpetual(self)
266    }
267
268    fn id(&self) -> InstrumentId {
269        self.id
270    }
271
272    fn raw_symbol(&self) -> Symbol {
273        self.raw_symbol
274    }
275
276    fn asset_class(&self) -> AssetClass {
277        AssetClass::Cryptocurrency
278    }
279
280    fn instrument_class(&self) -> InstrumentClass {
281        InstrumentClass::Swap
282    }
283    fn underlying(&self) -> Option<Ustr> {
284        None
285    }
286
287    fn base_currency(&self) -> Option<Currency> {
288        Some(self.base_currency)
289    }
290
291    fn quote_currency(&self) -> Currency {
292        self.quote_currency
293    }
294
295    fn settlement_currency(&self) -> Currency {
296        self.settlement_currency
297    }
298
299    fn isin(&self) -> Option<Ustr> {
300        None
301    }
302    fn option_kind(&self) -> Option<OptionKind> {
303        None
304    }
305    fn exchange(&self) -> Option<Ustr> {
306        None
307    }
308    fn strike_price(&self) -> Option<Price> {
309        None
310    }
311
312    fn activation_ns(&self) -> Option<UnixNanos> {
313        None
314    }
315
316    fn expiration_ns(&self) -> Option<UnixNanos> {
317        None
318    }
319
320    fn is_inverse(&self) -> bool {
321        self.is_inverse
322    }
323
324    fn price_precision(&self) -> u8 {
325        self.price_precision
326    }
327
328    fn size_precision(&self) -> u8 {
329        self.size_precision
330    }
331
332    fn price_increment(&self) -> Price {
333        self.price_increment
334    }
335
336    fn size_increment(&self) -> Quantity {
337        self.size_increment
338    }
339
340    fn multiplier(&self) -> Quantity {
341        self.multiplier
342    }
343
344    fn lot_size(&self) -> Option<Quantity> {
345        Some(self.lot_size)
346    }
347
348    fn max_quantity(&self) -> Option<Quantity> {
349        self.max_quantity
350    }
351
352    fn min_quantity(&self) -> Option<Quantity> {
353        self.min_quantity
354    }
355
356    fn max_notional(&self) -> Option<Money> {
357        self.max_notional
358    }
359
360    fn min_notional(&self) -> Option<Money> {
361        self.min_notional
362    }
363
364    fn max_price(&self) -> Option<Price> {
365        self.max_price
366    }
367
368    fn min_price(&self) -> Option<Price> {
369        self.min_price
370    }
371
372    fn margin_init(&self) -> Decimal {
373        self.margin_init
374    }
375
376    fn margin_maint(&self) -> Decimal {
377        self.margin_maint
378    }
379
380    fn maker_fee(&self) -> Decimal {
381        self.maker_fee
382    }
383
384    fn taker_fee(&self) -> Decimal {
385        self.taker_fee
386    }
387
388    fn ts_event(&self) -> UnixNanos {
389        self.ts_event
390    }
391
392    fn ts_init(&self) -> UnixNanos {
393        self.ts_init
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use rstest::rstest;
400
401    use crate::{
402        enums::{AssetClass, InstrumentClass},
403        identifiers::{InstrumentId, Symbol},
404        instruments::{CryptoPerpetual, Instrument, stubs::*},
405        types::{Currency, Money, Price, Quantity},
406    };
407
408    #[rstest]
409    fn test_trait_accessors(crypto_perpetual_ethusdt: CryptoPerpetual) {
410        assert_eq!(
411            crypto_perpetual_ethusdt.id(),
412            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
413        );
414        assert_eq!(
415            crypto_perpetual_ethusdt.asset_class(),
416            AssetClass::Cryptocurrency
417        );
418        assert_eq!(
419            crypto_perpetual_ethusdt.instrument_class(),
420            InstrumentClass::Swap
421        );
422        assert_eq!(
423            crypto_perpetual_ethusdt.base_currency(),
424            Some(Currency::ETH())
425        );
426        assert_eq!(crypto_perpetual_ethusdt.quote_currency(), Currency::USDT());
427        assert_eq!(
428            crypto_perpetual_ethusdt.settlement_currency(),
429            Currency::USDT()
430        );
431        assert!(!crypto_perpetual_ethusdt.is_inverse());
432        assert_eq!(crypto_perpetual_ethusdt.price_precision(), 2);
433        assert_eq!(crypto_perpetual_ethusdt.size_precision(), 3);
434        assert_eq!(
435            crypto_perpetual_ethusdt.price_increment(),
436            Price::from("0.01")
437        );
438        assert_eq!(
439            crypto_perpetual_ethusdt.size_increment(),
440            Quantity::from("0.001")
441        );
442        assert_eq!(crypto_perpetual_ethusdt.multiplier(), Quantity::from("1"));
443        assert_eq!(
444            crypto_perpetual_ethusdt.lot_size(),
445            Some(Quantity::from("1"))
446        );
447        assert_eq!(
448            crypto_perpetual_ethusdt.max_quantity(),
449            Some(Quantity::from("10000.0")),
450        );
451        assert_eq!(
452            crypto_perpetual_ethusdt.min_quantity(),
453            Some(Quantity::from("0.001")),
454        );
455        assert_eq!(
456            crypto_perpetual_ethusdt.min_notional(),
457            Some(Money::new(10.00, Currency::USDT())),
458        );
459        assert_eq!(crypto_perpetual_ethusdt.underlying(), None);
460        assert_eq!(crypto_perpetual_ethusdt.option_kind(), None);
461        assert_eq!(crypto_perpetual_ethusdt.strike_price(), None);
462        assert_eq!(crypto_perpetual_ethusdt.activation_ns(), None);
463        assert_eq!(crypto_perpetual_ethusdt.expiration_ns(), None);
464    }
465
466    #[rstest]
467    fn test_inverse_perp_accessors(xbtusd_bitmex: CryptoPerpetual) {
468        assert!(xbtusd_bitmex.is_inverse());
469        assert_eq!(xbtusd_bitmex.base_currency(), Some(Currency::BTC()));
470        assert_eq!(xbtusd_bitmex.quote_currency(), Currency::USD());
471        assert_eq!(xbtusd_bitmex.settlement_currency(), Currency::BTC());
472        assert_eq!(xbtusd_bitmex.cost_currency(), Currency::BTC());
473    }
474
475    #[rstest]
476    fn test_new_checked_price_precision_mismatch() {
477        let result = CryptoPerpetual::new_checked(
478            InstrumentId::from("TEST.EXCHANGE"),
479            Symbol::from("TEST"),
480            Currency::BTC(),
481            Currency::USDT(),
482            Currency::USDT(),
483            false,
484            3, // mismatch
485            0,
486            Price::from("0.01"),
487            Quantity::from("1"),
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            None,
501            0.into(),
502            0.into(),
503        );
504        assert!(result.is_err());
505    }
506
507    #[rstest]
508    fn test_new_checked_size_precision_mismatch() {
509        let result = CryptoPerpetual::new_checked(
510            InstrumentId::from("TEST.EXCHANGE"),
511            Symbol::from("TEST"),
512            Currency::BTC(),
513            Currency::USDT(),
514            Currency::USDT(),
515            false,
516            2,
517            5, // mismatch
518            Price::from("0.01"),
519            Quantity::from("1"),
520            None,
521            None,
522            None,
523            None,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None,
530            None,
531            None,
532            None,
533            0.into(),
534            0.into(),
535        );
536        assert!(result.is_err());
537    }
538
539    #[rstest]
540    fn test_serialization_roundtrip(crypto_perpetual_ethusdt: CryptoPerpetual) {
541        let json = serde_json::to_string(&crypto_perpetual_ethusdt).unwrap();
542        let deserialized: CryptoPerpetual = serde_json::from_str(&json).unwrap();
543        assert_eq!(crypto_perpetual_ethusdt, deserialized);
544    }
545}