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 into_any(self) -> InstrumentAny {
259        InstrumentAny::Cfd(self)
260    }
261
262    fn id(&self) -> InstrumentId {
263        self.id
264    }
265
266    fn raw_symbol(&self) -> Symbol {
267        self.raw_symbol
268    }
269
270    fn asset_class(&self) -> AssetClass {
271        self.asset_class
272    }
273
274    fn instrument_class(&self) -> InstrumentClass {
275        InstrumentClass::Cfd
276    }
277
278    fn underlying(&self) -> Option<Ustr> {
279        None
280    }
281
282    fn base_currency(&self) -> Option<Currency> {
283        self.base_currency
284    }
285
286    fn quote_currency(&self) -> Currency {
287        self.quote_currency
288    }
289
290    fn settlement_currency(&self) -> Currency {
291        self.quote_currency
292    }
293
294    fn isin(&self) -> Option<Ustr> {
295        None
296    }
297
298    fn option_kind(&self) -> Option<OptionKind> {
299        None
300    }
301
302    fn exchange(&self) -> Option<Ustr> {
303        None
304    }
305
306    fn strike_price(&self) -> Option<Price> {
307        None
308    }
309
310    fn activation_ns(&self) -> Option<UnixNanos> {
311        None
312    }
313
314    fn expiration_ns(&self) -> Option<UnixNanos> {
315        None
316    }
317
318    fn is_inverse(&self) -> bool {
319        false
320    }
321
322    fn price_precision(&self) -> u8 {
323        self.price_precision
324    }
325
326    fn size_precision(&self) -> u8 {
327        self.size_precision
328    }
329
330    fn price_increment(&self) -> Price {
331        self.price_increment
332    }
333
334    fn size_increment(&self) -> Quantity {
335        self.size_increment
336    }
337
338    fn multiplier(&self) -> Quantity {
339        Quantity::from(1)
340    }
341
342    fn lot_size(&self) -> Option<Quantity> {
343        self.lot_size
344    }
345
346    fn max_quantity(&self) -> Option<Quantity> {
347        self.max_quantity
348    }
349
350    fn min_quantity(&self) -> Option<Quantity> {
351        self.min_quantity
352    }
353
354    fn max_notional(&self) -> Option<Money> {
355        self.max_notional
356    }
357
358    fn min_notional(&self) -> Option<Money> {
359        self.min_notional
360    }
361
362    fn max_price(&self) -> Option<Price> {
363        self.max_price
364    }
365
366    fn min_price(&self) -> Option<Price> {
367        self.min_price
368    }
369
370    fn margin_init(&self) -> Decimal {
371        self.margin_init
372    }
373
374    fn margin_maint(&self) -> Decimal {
375        self.margin_maint
376    }
377
378    fn maker_fee(&self) -> Decimal {
379        self.maker_fee
380    }
381
382    fn taker_fee(&self) -> Decimal {
383        self.taker_fee
384    }
385
386    fn tick_scheme(&self) -> Option<Ustr> {
387        self.tick_scheme
388    }
389
390    fn info(&self) -> Option<&Params> {
391        self.info.as_ref()
392    }
393
394    fn ts_event(&self) -> UnixNanos {
395        self.ts_event
396    }
397
398    fn ts_init(&self) -> UnixNanos {
399        self.ts_init
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use rstest::rstest;
406    use rust_decimal_macros::dec;
407
408    use crate::{
409        enums::{AssetClass, InstrumentClass},
410        identifiers::{InstrumentId, Symbol},
411        instruments::{Cfd, Instrument, stubs::*},
412        types::{Currency, Money, Price, Quantity},
413    };
414
415    #[rstest]
416    fn test_trait_accessors(cfd_gold: Cfd) {
417        assert_eq!(cfd_gold.id(), InstrumentId::from("GOLD-CFD.SIM"));
418        assert_eq!(cfd_gold.asset_class(), AssetClass::Commodity);
419        assert_eq!(cfd_gold.instrument_class(), InstrumentClass::Cfd);
420        assert_eq!(cfd_gold.quote_currency(), Currency::USD());
421        assert!(!cfd_gold.is_inverse());
422        assert_eq!(cfd_gold.price_precision(), 2);
423        assert_eq!(cfd_gold.size_precision(), 0);
424    }
425
426    #[rstest]
427    fn test_new_checked_price_precision_mismatch() {
428        let result = Cfd::new_checked(
429            InstrumentId::from("TEST.SIM"),
430            Symbol::from("TEST"),
431            AssetClass::Commodity,
432            None,
433            Currency::USD(),
434            4, // mismatch
435            0,
436            Price::from("0.01"),
437            Quantity::from("1"),
438            None,
439            None,
440            None,
441            None,
442            None,
443            None,
444            None,
445            None,
446            None,
447            None,
448            None,
449            None,
450            None,
451            0.into(),
452            0.into(),
453        );
454        assert!(result.is_err());
455    }
456
457    #[rstest]
458    fn test_new_checked_rejects_non_positive_lot_size() {
459        let result = Cfd::new_checked(
460            InstrumentId::from("TEST.SIM"),
461            Symbol::from("TEST"),
462            AssetClass::Commodity,
463            None,
464            Currency::USD(),
465            2,
466            0,
467            Price::from("0.01"),
468            Quantity::from("1"),
469            Some(Quantity::from("0")),
470            None,
471            None,
472            None,
473            None,
474            None,
475            None,
476            None,
477            None,
478            None,
479            None,
480            None,
481            None,
482            0.into(),
483            0.into(),
484        );
485        let error = result.unwrap_err();
486        assert!(error.to_string().contains("not positive"), "{error}");
487    }
488
489    #[rstest]
490    fn test_serialization_roundtrip(cfd_gold: Cfd) {
491        let json = serde_json::to_string(&cfd_gold).unwrap();
492        let deserialized: Cfd = serde_json::from_str(&json).unwrap();
493        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
494    }
495
496    #[rstest]
497    fn test_builder_matches_new_checked() {
498        let positional = Cfd::new_checked(
499            InstrumentId::from("EURUSD-CFD.SIM"),
500            Symbol::from("EURUSD-CFD"),
501            AssetClass::FX,
502            Some(Currency::EUR()),
503            Currency::USD(),
504            5,
505            2,
506            Price::from("0.00001"),
507            Quantity::from("0.01"),
508            Some(Quantity::from("100")),
509            Some(Quantity::from("10000.00")),
510            Some(Quantity::from("5.00")),
511            Some(Money::from("1000000 USD")),
512            Some(Money::from("100 USD")),
513            Some(Price::from("2.00000")),
514            Some(Price::from("0.50000")),
515            Some(dec!(0.01)),
516            Some(dec!(0.02)),
517            Some(dec!(0.0002)),
518            Some(dec!(0.0004)),
519            None,
520            None,
521            1.into(),
522            2.into(),
523        )
524        .unwrap();
525
526        let built = Cfd::builder()
527            .instrument_id(InstrumentId::from("EURUSD-CFD.SIM"))
528            .raw_symbol(Symbol::from("EURUSD-CFD"))
529            .asset_class(AssetClass::FX)
530            .base_currency(Currency::EUR())
531            .quote_currency(Currency::USD())
532            .price_precision(5)
533            .size_precision(2)
534            .price_increment(Price::from("0.00001"))
535            .size_increment(Quantity::from("0.01"))
536            .lot_size(Quantity::from("100"))
537            .max_quantity(Quantity::from("10000.00"))
538            .min_quantity(Quantity::from("5.00"))
539            .max_notional(Money::from("1000000 USD"))
540            .min_notional(Money::from("100 USD"))
541            .max_price(Price::from("2.00000"))
542            .min_price(Price::from("0.50000"))
543            .margin_init(dec!(0.01))
544            .margin_maint(dec!(0.02))
545            .maker_fee(dec!(0.0002))
546            .taker_fee(dec!(0.0004))
547            .ts_event(1.into())
548            .ts_init(2.into())
549            .build()
550            .unwrap();
551
552        assert_eq!(
553            serde_json::to_value(&positional).unwrap(),
554            serde_json::to_value(&built).unwrap(),
555        );
556    }
557}