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, 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.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    #[expect(clippy::too_many_arguments)]
105    fn new_checked(
106        instrument_id: InstrumentId,
107        raw_symbol: Symbol,
108        asset_class: AssetClass,
109        base_currency: Option<Currency>,
110        quote_currency: Currency,
111        price_precision: u8,
112        size_precision: u8,
113        price_increment: Price,
114        size_increment: Quantity,
115        lot_size: Option<Quantity>,
116        max_quantity: Option<Quantity>,
117        min_quantity: Option<Quantity>,
118        max_notional: Option<Money>,
119        min_notional: Option<Money>,
120        max_price: Option<Price>,
121        min_price: Option<Price>,
122        margin_init: Option<Decimal>,
123        margin_maint: Option<Decimal>,
124        maker_fee: Option<Decimal>,
125        taker_fee: Option<Decimal>,
126        tick_scheme: Option<Ustr>,
127        info: Option<Params>,
128        ts_event: UnixNanos,
129        ts_init: UnixNanos,
130    ) -> CorrectnessResult<Self> {
131        check_equal_u8(
132            price_precision,
133            price_increment.precision,
134            stringify!(price_precision),
135            stringify!(price_increment.precision),
136        )?;
137        check_equal_u8(
138            size_precision,
139            size_increment.precision,
140            stringify!(size_precision),
141            stringify!(size_increment.precision),
142        )?;
143        check_positive_price(price_increment, stringify!(price_increment))?;
144        check_positive_quantity(size_increment, stringify!(size_increment))?;
145        check_tick_scheme(tick_scheme)?;
146
147        if let Some(lot_size) = lot_size {
148            check_positive_quantity(lot_size, stringify!(lot_size))?;
149        }
150
151        Ok(Self {
152            id: instrument_id,
153            raw_symbol,
154            asset_class,
155            base_currency,
156            quote_currency,
157            price_precision,
158            size_precision,
159            price_increment,
160            size_increment,
161            lot_size,
162            max_quantity,
163            min_quantity,
164            max_notional,
165            min_notional,
166            max_price,
167            min_price,
168            margin_init: margin_init.unwrap_or_default(),
169            margin_maint: margin_maint.unwrap_or_default(),
170            maker_fee: maker_fee.unwrap_or_default(),
171            taker_fee: taker_fee.unwrap_or_default(),
172            tick_scheme,
173            info,
174            ts_event,
175            ts_init,
176        })
177    }
178
179    /// Returns a fluent builder for a [`Cfd`] instance.
180    ///
181    /// Required fields are enforced at compile time; optional fields can be omitted and use the
182    /// same defaults as checked construction. The same correctness checks run on `build`.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if any input validation fails.
187    #[builder(start_fn = builder, finish_fn = build)]
188    pub fn build_checked(
189        instrument_id: InstrumentId,
190        raw_symbol: Symbol,
191        asset_class: AssetClass,
192        base_currency: Option<Currency>,
193        quote_currency: Currency,
194        price_precision: u8,
195        size_precision: u8,
196        price_increment: Price,
197        size_increment: Quantity,
198        lot_size: Option<Quantity>,
199        max_quantity: Option<Quantity>,
200        min_quantity: Option<Quantity>,
201        max_notional: Option<Money>,
202        min_notional: Option<Money>,
203        max_price: Option<Price>,
204        min_price: Option<Price>,
205        margin_init: Option<Decimal>,
206        margin_maint: Option<Decimal>,
207        maker_fee: Option<Decimal>,
208        taker_fee: Option<Decimal>,
209        tick_scheme: Option<Ustr>,
210        info: Option<Params>,
211        ts_event: UnixNanos,
212        ts_init: UnixNanos,
213    ) -> CorrectnessResult<Self> {
214        Self::new_checked(
215            instrument_id,
216            raw_symbol,
217            asset_class,
218            base_currency,
219            quote_currency,
220            price_precision,
221            size_precision,
222            price_increment,
223            size_increment,
224            lot_size,
225            max_quantity,
226            min_quantity,
227            max_notional,
228            min_notional,
229            max_price,
230            min_price,
231            margin_init,
232            margin_maint,
233            maker_fee,
234            taker_fee,
235            tick_scheme,
236            info,
237            ts_event,
238            ts_init,
239        )
240    }
241}
242
243impl PartialEq<Self> for Cfd {
244    fn eq(&self, other: &Self) -> bool {
245        self.id == other.id
246    }
247}
248
249impl Eq for Cfd {}
250
251impl Hash for Cfd {
252    fn hash<H: Hasher>(&self, state: &mut H) {
253        self.id.hash(state);
254    }
255}
256
257impl Instrument for Cfd {
258    fn tick_scheme(&self) -> Option<Ustr> {
259        self.tick_scheme
260    }
261    fn into_any(self) -> InstrumentAny {
262        InstrumentAny::Cfd(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        self.asset_class
275    }
276
277    fn instrument_class(&self) -> InstrumentClass {
278        InstrumentClass::Cfd
279    }
280
281    fn underlying(&self) -> Option<Ustr> {
282        None
283    }
284
285    fn base_currency(&self) -> Option<Currency> {
286        self.base_currency
287    }
288
289    fn quote_currency(&self) -> Currency {
290        self.quote_currency
291    }
292
293    fn settlement_currency(&self) -> Currency {
294        self.quote_currency
295    }
296
297    fn isin(&self) -> Option<Ustr> {
298        None
299    }
300
301    fn option_kind(&self) -> Option<OptionKind> {
302        None
303    }
304
305    fn exchange(&self) -> Option<Ustr> {
306        None
307    }
308
309    fn strike_price(&self) -> Option<Price> {
310        None
311    }
312
313    fn activation_ns(&self) -> Option<UnixNanos> {
314        None
315    }
316
317    fn expiration_ns(&self) -> Option<UnixNanos> {
318        None
319    }
320
321    fn is_inverse(&self) -> bool {
322        false
323    }
324
325    fn price_precision(&self) -> u8 {
326        self.price_precision
327    }
328
329    fn size_precision(&self) -> u8 {
330        self.size_precision
331    }
332
333    fn price_increment(&self) -> Price {
334        self.price_increment
335    }
336
337    fn size_increment(&self) -> Quantity {
338        self.size_increment
339    }
340
341    fn multiplier(&self) -> Quantity {
342        Quantity::from(1)
343    }
344
345    fn lot_size(&self) -> Option<Quantity> {
346        self.lot_size
347    }
348
349    fn max_quantity(&self) -> Option<Quantity> {
350        self.max_quantity
351    }
352
353    fn min_quantity(&self) -> Option<Quantity> {
354        self.min_quantity
355    }
356
357    fn max_notional(&self) -> Option<Money> {
358        self.max_notional
359    }
360
361    fn min_notional(&self) -> Option<Money> {
362        self.min_notional
363    }
364
365    fn max_price(&self) -> Option<Price> {
366        self.max_price
367    }
368
369    fn min_price(&self) -> Option<Price> {
370        self.min_price
371    }
372
373    fn margin_init(&self) -> Decimal {
374        self.margin_init
375    }
376
377    fn margin_maint(&self) -> Decimal {
378        self.margin_maint
379    }
380
381    fn maker_fee(&self) -> Decimal {
382        self.maker_fee
383    }
384
385    fn taker_fee(&self) -> Decimal {
386        self.taker_fee
387    }
388
389    fn ts_event(&self) -> UnixNanos {
390        self.ts_event
391    }
392
393    fn ts_init(&self) -> UnixNanos {
394        self.ts_init
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use rstest::rstest;
401    use rust_decimal_macros::dec;
402
403    use crate::{
404        enums::{AssetClass, InstrumentClass},
405        identifiers::{InstrumentId, Symbol},
406        instruments::{Cfd, Instrument, stubs::*},
407        types::{Currency, Money, Price, Quantity},
408    };
409
410    #[rstest]
411    fn test_trait_accessors(cfd_gold: Cfd) {
412        assert_eq!(cfd_gold.id(), InstrumentId::from("GOLD-CFD.SIM"));
413        assert_eq!(cfd_gold.asset_class(), AssetClass::Commodity);
414        assert_eq!(cfd_gold.instrument_class(), InstrumentClass::Cfd);
415        assert_eq!(cfd_gold.quote_currency(), Currency::USD());
416        assert!(!cfd_gold.is_inverse());
417        assert_eq!(cfd_gold.price_precision(), 2);
418        assert_eq!(cfd_gold.size_precision(), 0);
419    }
420
421    #[rstest]
422    fn test_new_checked_price_precision_mismatch() {
423        let result = Cfd::new_checked(
424            InstrumentId::from("TEST.SIM"),
425            Symbol::from("TEST"),
426            AssetClass::Commodity,
427            None,
428            Currency::USD(),
429            4, // mismatch
430            0,
431            Price::from("0.01"),
432            Quantity::from("1"),
433            None,
434            None,
435            None,
436            None,
437            None,
438            None,
439            None,
440            None,
441            None,
442            None,
443            None,
444            None,
445            None,
446            0.into(),
447            0.into(),
448        );
449        assert!(result.is_err());
450    }
451
452    #[rstest]
453    fn test_new_checked_rejects_non_positive_lot_size() {
454        let result = Cfd::new_checked(
455            InstrumentId::from("TEST.SIM"),
456            Symbol::from("TEST"),
457            AssetClass::Commodity,
458            None,
459            Currency::USD(),
460            2,
461            0,
462            Price::from("0.01"),
463            Quantity::from("1"),
464            Some(Quantity::from("0")),
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471            None,
472            None,
473            None,
474            None,
475            None,
476            None,
477            0.into(),
478            0.into(),
479        );
480        let error = result.unwrap_err();
481        assert!(error.to_string().contains("not positive"), "{error}");
482    }
483
484    #[rstest]
485    fn test_serialization_roundtrip(cfd_gold: Cfd) {
486        let json = serde_json::to_string(&cfd_gold).unwrap();
487        let deserialized: Cfd = serde_json::from_str(&json).unwrap();
488        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
489    }
490
491    #[rstest]
492    fn test_builder_matches_new_checked() {
493        let positional = Cfd::new_checked(
494            InstrumentId::from("EURUSD-CFD.SIM"),
495            Symbol::from("EURUSD-CFD"),
496            AssetClass::FX,
497            Some(Currency::EUR()),
498            Currency::USD(),
499            5,
500            2,
501            Price::from("0.00001"),
502            Quantity::from("0.01"),
503            Some(Quantity::from("100")),
504            Some(Quantity::from("10000.00")),
505            Some(Quantity::from("5.00")),
506            Some(Money::from("1000000 USD")),
507            Some(Money::from("100 USD")),
508            Some(Price::from("2.00000")),
509            Some(Price::from("0.50000")),
510            Some(dec!(0.01)),
511            Some(dec!(0.02)),
512            Some(dec!(0.0002)),
513            Some(dec!(0.0004)),
514            None,
515            None,
516            1.into(),
517            2.into(),
518        )
519        .unwrap();
520
521        let built = Cfd::builder()
522            .instrument_id(InstrumentId::from("EURUSD-CFD.SIM"))
523            .raw_symbol(Symbol::from("EURUSD-CFD"))
524            .asset_class(AssetClass::FX)
525            .base_currency(Currency::EUR())
526            .quote_currency(Currency::USD())
527            .price_precision(5)
528            .size_precision(2)
529            .price_increment(Price::from("0.00001"))
530            .size_increment(Quantity::from("0.01"))
531            .lot_size(Quantity::from("100"))
532            .max_quantity(Quantity::from("10000.00"))
533            .min_quantity(Quantity::from("5.00"))
534            .max_notional(Money::from("1000000 USD"))
535            .min_notional(Money::from("100 USD"))
536            .max_price(Price::from("2.00000"))
537            .min_price(Price::from("0.50000"))
538            .margin_init(dec!(0.01))
539            .margin_maint(dec!(0.02))
540            .maker_fee(dec!(0.0002))
541            .taker_fee(dec!(0.0004))
542            .ts_event(1.into())
543            .ts_init(2.into())
544            .build()
545            .unwrap();
546
547        assert_eq!(
548            serde_json::to_value(&positional).unwrap(),
549            serde_json::to_value(&built).unwrap(),
550        );
551    }
552}