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