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