Skip to main content

nautilus_model/instruments/
cfd.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::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
27use crate::{
28    enums::{AssetClass, InstrumentClass, OptionKind},
29    identifiers::{InstrumentId, Symbol},
30    types::{
31        currency::Currency,
32        money::Money,
33        price::{Price, check_positive_price},
34        quantity::{Quantity, check_positive_quantity},
35    },
36};
37
38/// Represents a Contract for Difference (CFD) instrument.
39///
40/// Can represent both Fiat FX and Cryptocurrency pairs.
41#[repr(C)]
42#[derive(Clone, Debug, Serialize, Deserialize)]
43#[cfg_attr(
44    feature = "python",
45    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
46)]
47#[cfg_attr(
48    feature = "python",
49    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
50)]
51pub struct Cfd {
52    /// The instrument ID.
53    pub id: InstrumentId,
54    /// The raw/local/native symbol for the instrument, assigned by the venue.
55    pub raw_symbol: Symbol,
56    /// The asset class of the CFD.
57    pub asset_class: AssetClass,
58    /// The base currency (optional).
59    pub base_currency: Option<Currency>,
60    /// The quote currency.
61    pub quote_currency: Currency,
62    /// The price decimal precision.
63    pub price_precision: u8,
64    /// The trading size decimal precision.
65    pub size_precision: u8,
66    /// The minimum price increment (tick size).
67    pub price_increment: Price,
68    /// The minimum size increment.
69    pub size_increment: Quantity,
70    /// The initial (order) margin requirement in percentage of order value.
71    pub margin_init: Decimal,
72    /// The maintenance (position) margin in percentage of position value.
73    pub margin_maint: Decimal,
74    /// The fee rate for liquidity makers as a percentage of order value.
75    pub maker_fee: Decimal,
76    /// The fee rate for liquidity takers as a percentage of order value.
77    pub taker_fee: Decimal,
78    /// The rounded lot unit size (standard/board).
79    pub lot_size: Option<Quantity>,
80    /// The maximum allowable order quantity.
81    pub max_quantity: Option<Quantity>,
82    /// The minimum allowable order quantity.
83    pub min_quantity: Option<Quantity>,
84    /// The maximum allowable order notional value.
85    pub max_notional: Option<Money>,
86    /// The minimum allowable order notional value.
87    pub min_notional: Option<Money>,
88    /// The maximum allowable quoted price.
89    pub max_price: Option<Price>,
90    /// The minimum allowable quoted price.
91    pub min_price: Option<Price>,
92    /// The registered variable tick scheme name.
93    pub tick_scheme: Option<Ustr>,
94    /// Additional instrument metadata as a JSON-serializable dictionary.
95    pub info: Option<Params>,
96    /// UNIX timestamp (nanoseconds) when the data event occurred.
97    pub ts_event: UnixNanos,
98    /// UNIX timestamp (nanoseconds) when the data object was initialized.
99    pub ts_init: UnixNanos,
100}
101
102#[bon::bon]
103impl Cfd {
104    /// Creates a new [`Cfd`] instance with correctness checking.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if any input validation fails.
109    ///
110    /// # Notes
111    ///
112    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
113    #[expect(clippy::too_many_arguments)]
114    pub fn new_checked(
115        instrument_id: InstrumentId,
116        raw_symbol: Symbol,
117        asset_class: AssetClass,
118        base_currency: Option<Currency>,
119        quote_currency: Currency,
120        price_precision: u8,
121        size_precision: u8,
122        price_increment: Price,
123        size_increment: Quantity,
124        lot_size: Option<Quantity>,
125        max_quantity: Option<Quantity>,
126        min_quantity: Option<Quantity>,
127        max_notional: Option<Money>,
128        min_notional: Option<Money>,
129        max_price: Option<Price>,
130        min_price: Option<Price>,
131        margin_init: Option<Decimal>,
132        margin_maint: Option<Decimal>,
133        maker_fee: Option<Decimal>,
134        taker_fee: Option<Decimal>,
135        tick_scheme: Option<Ustr>,
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        check_tick_scheme(tick_scheme)?;
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            asset_class,
164            base_currency,
165            quote_currency,
166            price_precision,
167            size_precision,
168            price_increment,
169            size_increment,
170            lot_size,
171            max_quantity,
172            min_quantity,
173            max_notional,
174            min_notional,
175            max_price,
176            min_price,
177            margin_init: margin_init.unwrap_or_default(),
178            margin_maint: margin_maint.unwrap_or_default(),
179            maker_fee: maker_fee.unwrap_or_default(),
180            taker_fee: taker_fee.unwrap_or_default(),
181            tick_scheme,
182            info,
183            ts_event,
184            ts_init,
185        })
186    }
187
188    /// Creates a new [`Cfd`] instance.
189    ///
190    /// # Panics
191    ///
192    /// Panics if any parameter is invalid (see `new_checked`).
193    #[expect(clippy::too_many_arguments)]
194    #[must_use]
195    pub fn new(
196        instrument_id: InstrumentId,
197        raw_symbol: Symbol,
198        asset_class: AssetClass,
199        base_currency: Option<Currency>,
200        quote_currency: Currency,
201        price_precision: u8,
202        size_precision: u8,
203        price_increment: Price,
204        size_increment: Quantity,
205        lot_size: Option<Quantity>,
206        max_quantity: Option<Quantity>,
207        min_quantity: Option<Quantity>,
208        max_notional: Option<Money>,
209        min_notional: Option<Money>,
210        max_price: Option<Price>,
211        min_price: Option<Price>,
212        margin_init: Option<Decimal>,
213        margin_maint: Option<Decimal>,
214        maker_fee: Option<Decimal>,
215        taker_fee: Option<Decimal>,
216        tick_scheme: Option<Ustr>,
217        info: Option<Params>,
218        ts_event: UnixNanos,
219        ts_init: UnixNanos,
220    ) -> Self {
221        Self::new_checked(
222            instrument_id,
223            raw_symbol,
224            asset_class,
225            base_currency,
226            quote_currency,
227            price_precision,
228            size_precision,
229            price_increment,
230            size_increment,
231            lot_size,
232            max_quantity,
233            min_quantity,
234            max_notional,
235            min_notional,
236            max_price,
237            min_price,
238            margin_init,
239            margin_maint,
240            maker_fee,
241            taker_fee,
242            tick_scheme,
243            info,
244            ts_event,
245            ts_init,
246        )
247        .expect_display(FAILED)
248    }
249
250    /// Returns a fluent builder for a [`Cfd`] instance.
251    ///
252    /// Required fields are enforced at compile time; optional fields can be omitted and default
253    /// the same way they do in [`Cfd::new_checked`], which the builder calls so the
254    /// same correctness checks run on `build`.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if any input validation fails (see [`Cfd::new_checked`]).
259    #[builder(start_fn = builder, finish_fn = build)]
260    pub fn build_checked(
261        instrument_id: InstrumentId,
262        raw_symbol: Symbol,
263        asset_class: AssetClass,
264        base_currency: Option<Currency>,
265        quote_currency: Currency,
266        price_precision: u8,
267        size_precision: u8,
268        price_increment: Price,
269        size_increment: Quantity,
270        lot_size: Option<Quantity>,
271        max_quantity: Option<Quantity>,
272        min_quantity: Option<Quantity>,
273        max_notional: Option<Money>,
274        min_notional: Option<Money>,
275        max_price: Option<Price>,
276        min_price: Option<Price>,
277        margin_init: Option<Decimal>,
278        margin_maint: Option<Decimal>,
279        maker_fee: Option<Decimal>,
280        taker_fee: Option<Decimal>,
281        tick_scheme: Option<Ustr>,
282        info: Option<Params>,
283        ts_event: UnixNanos,
284        ts_init: UnixNanos,
285    ) -> CorrectnessResult<Self> {
286        Self::new_checked(
287            instrument_id,
288            raw_symbol,
289            asset_class,
290            base_currency,
291            quote_currency,
292            price_precision,
293            size_precision,
294            price_increment,
295            size_increment,
296            lot_size,
297            max_quantity,
298            min_quantity,
299            max_notional,
300            min_notional,
301            max_price,
302            min_price,
303            margin_init,
304            margin_maint,
305            maker_fee,
306            taker_fee,
307            tick_scheme,
308            info,
309            ts_event,
310            ts_init,
311        )
312    }
313}
314
315impl PartialEq<Self> for Cfd {
316    fn eq(&self, other: &Self) -> bool {
317        self.id == other.id
318    }
319}
320
321impl Eq for Cfd {}
322
323impl Hash for Cfd {
324    fn hash<H: Hasher>(&self, state: &mut H) {
325        self.id.hash(state);
326    }
327}
328
329impl Instrument for Cfd {
330    fn tick_scheme(&self) -> Option<Ustr> {
331        self.tick_scheme
332    }
333    fn into_any(self) -> InstrumentAny {
334        InstrumentAny::Cfd(self)
335    }
336
337    fn id(&self) -> InstrumentId {
338        self.id
339    }
340
341    fn raw_symbol(&self) -> Symbol {
342        self.raw_symbol
343    }
344
345    fn asset_class(&self) -> AssetClass {
346        self.asset_class
347    }
348
349    fn instrument_class(&self) -> InstrumentClass {
350        InstrumentClass::Cfd
351    }
352
353    fn underlying(&self) -> Option<Ustr> {
354        None
355    }
356
357    fn base_currency(&self) -> Option<Currency> {
358        self.base_currency
359    }
360
361    fn quote_currency(&self) -> Currency {
362        self.quote_currency
363    }
364
365    fn settlement_currency(&self) -> Currency {
366        self.quote_currency
367    }
368
369    fn isin(&self) -> Option<Ustr> {
370        None
371    }
372
373    fn option_kind(&self) -> Option<OptionKind> {
374        None
375    }
376
377    fn exchange(&self) -> Option<Ustr> {
378        None
379    }
380
381    fn strike_price(&self) -> Option<Price> {
382        None
383    }
384
385    fn activation_ns(&self) -> Option<UnixNanos> {
386        None
387    }
388
389    fn expiration_ns(&self) -> Option<UnixNanos> {
390        None
391    }
392
393    fn is_inverse(&self) -> bool {
394        false
395    }
396
397    fn price_precision(&self) -> u8 {
398        self.price_precision
399    }
400
401    fn size_precision(&self) -> u8 {
402        self.size_precision
403    }
404
405    fn price_increment(&self) -> Price {
406        self.price_increment
407    }
408
409    fn size_increment(&self) -> Quantity {
410        self.size_increment
411    }
412
413    fn multiplier(&self) -> Quantity {
414        Quantity::from(1)
415    }
416
417    fn lot_size(&self) -> Option<Quantity> {
418        self.lot_size
419    }
420
421    fn max_quantity(&self) -> Option<Quantity> {
422        self.max_quantity
423    }
424
425    fn min_quantity(&self) -> Option<Quantity> {
426        self.min_quantity
427    }
428
429    fn max_notional(&self) -> Option<Money> {
430        self.max_notional
431    }
432
433    fn min_notional(&self) -> Option<Money> {
434        self.min_notional
435    }
436
437    fn max_price(&self) -> Option<Price> {
438        self.max_price
439    }
440
441    fn min_price(&self) -> Option<Price> {
442        self.min_price
443    }
444
445    fn margin_init(&self) -> Decimal {
446        self.margin_init
447    }
448
449    fn margin_maint(&self) -> Decimal {
450        self.margin_maint
451    }
452
453    fn maker_fee(&self) -> Decimal {
454        self.maker_fee
455    }
456
457    fn taker_fee(&self) -> Decimal {
458        self.taker_fee
459    }
460
461    fn ts_event(&self) -> UnixNanos {
462        self.ts_event
463    }
464
465    fn ts_init(&self) -> UnixNanos {
466        self.ts_init
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use rstest::rstest;
473    use rust_decimal_macros::dec;
474
475    use crate::{
476        enums::{AssetClass, InstrumentClass},
477        identifiers::{InstrumentId, Symbol},
478        instruments::{Cfd, Instrument, stubs::*},
479        types::{Currency, Money, Price, Quantity},
480    };
481
482    #[rstest]
483    fn test_trait_accessors(cfd_gold: Cfd) {
484        assert_eq!(cfd_gold.id(), InstrumentId::from("GOLD-CFD.SIM"));
485        assert_eq!(cfd_gold.asset_class(), AssetClass::Commodity);
486        assert_eq!(cfd_gold.instrument_class(), InstrumentClass::Cfd);
487        assert_eq!(cfd_gold.quote_currency(), Currency::USD());
488        assert!(!cfd_gold.is_inverse());
489        assert_eq!(cfd_gold.price_precision(), 2);
490        assert_eq!(cfd_gold.size_precision(), 0);
491    }
492
493    #[rstest]
494    fn test_new_checked_price_precision_mismatch() {
495        let result = Cfd::new_checked(
496            InstrumentId::from("TEST.SIM"),
497            Symbol::from("TEST"),
498            AssetClass::Commodity,
499            None,
500            Currency::USD(),
501            4, // mismatch
502            0,
503            Price::from("0.01"),
504            Quantity::from("1"),
505            None,
506            None,
507            None,
508            None,
509            None,
510            None,
511            None,
512            None,
513            None,
514            None,
515            None,
516            None,
517            None,
518            0.into(),
519            0.into(),
520        );
521        assert!(result.is_err());
522    }
523
524    #[rstest]
525    fn test_new_checked_rejects_non_positive_lot_size() {
526        let result = Cfd::new_checked(
527            InstrumentId::from("TEST.SIM"),
528            Symbol::from("TEST"),
529            AssetClass::Commodity,
530            None,
531            Currency::USD(),
532            2,
533            0,
534            Price::from("0.01"),
535            Quantity::from("1"),
536            Some(Quantity::from("0")),
537            None,
538            None,
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            None,
547            None,
548            None,
549            0.into(),
550            0.into(),
551        );
552        let error = result.unwrap_err();
553        assert!(error.to_string().contains("not positive"), "{error}");
554    }
555
556    #[rstest]
557    fn test_serialization_roundtrip(cfd_gold: Cfd) {
558        let json = serde_json::to_string(&cfd_gold).unwrap();
559        let deserialized: Cfd = serde_json::from_str(&json).unwrap();
560        assert_eq!(cfd_gold, deserialized);
561    }
562
563    #[rstest]
564    fn test_builder_matches_new_checked() {
565        let positional = Cfd::new_checked(
566            InstrumentId::from("EURUSD-CFD.SIM"),
567            Symbol::from("EURUSD-CFD"),
568            AssetClass::FX,
569            Some(Currency::EUR()),
570            Currency::USD(),
571            5,
572            2,
573            Price::from("0.00001"),
574            Quantity::from("0.01"),
575            Some(Quantity::from("100")),
576            Some(Quantity::from("10000.00")),
577            Some(Quantity::from("5.00")),
578            Some(Money::from("1000000 USD")),
579            Some(Money::from("100 USD")),
580            Some(Price::from("2.00000")),
581            Some(Price::from("0.50000")),
582            Some(dec!(0.01)),
583            Some(dec!(0.02)),
584            Some(dec!(0.0002)),
585            Some(dec!(0.0004)),
586            None,
587            None,
588            1.into(),
589            2.into(),
590        )
591        .unwrap();
592
593        let built = Cfd::builder()
594            .instrument_id(InstrumentId::from("EURUSD-CFD.SIM"))
595            .raw_symbol(Symbol::from("EURUSD-CFD"))
596            .asset_class(AssetClass::FX)
597            .base_currency(Currency::EUR())
598            .quote_currency(Currency::USD())
599            .price_precision(5)
600            .size_precision(2)
601            .price_increment(Price::from("0.00001"))
602            .size_increment(Quantity::from("0.01"))
603            .lot_size(Quantity::from("100"))
604            .max_quantity(Quantity::from("10000.00"))
605            .min_quantity(Quantity::from("5.00"))
606            .max_notional(Money::from("1000000 USD"))
607            .min_notional(Money::from("100 USD"))
608            .max_price(Price::from("2.00000"))
609            .min_price(Price::from("0.50000"))
610            .margin_init(dec!(0.01))
611            .margin_maint(dec!(0.02))
612            .maker_fee(dec!(0.0002))
613            .taker_fee(dec!(0.0004))
614            .ts_event(1.into())
615            .ts_init(2.into())
616            .build()
617            .unwrap();
618
619        assert_eq!(
620            serde_json::to_value(&positional).unwrap(),
621            serde_json::to_value(&built).unwrap(),
622        );
623    }
624}