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