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