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};
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    /// Additional instrument metadata as a JSON-serializable dictionary.
93    pub info: Option<Params>,
94    /// UNIX timestamp (nanoseconds) when the data event occurred.
95    pub ts_event: UnixNanos,
96    /// UNIX timestamp (nanoseconds) when the data object was initialized.
97    pub ts_init: UnixNanos,
98}
99
100impl Cfd {
101    /// Creates a new [`Cfd`] instance with correctness checking.
102    ///
103    /// # Notes
104    ///
105    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
106    /// # Errors
107    ///
108    /// Returns an error if any input validation fails.
109    #[expect(clippy::too_many_arguments)]
110    pub fn new_checked(
111        instrument_id: InstrumentId,
112        raw_symbol: Symbol,
113        asset_class: AssetClass,
114        base_currency: Option<Currency>,
115        quote_currency: Currency,
116        price_precision: u8,
117        size_precision: u8,
118        price_increment: Price,
119        size_increment: Quantity,
120        lot_size: Option<Quantity>,
121        max_quantity: Option<Quantity>,
122        min_quantity: Option<Quantity>,
123        max_notional: Option<Money>,
124        min_notional: Option<Money>,
125        max_price: Option<Price>,
126        min_price: Option<Price>,
127        margin_init: Option<Decimal>,
128        margin_maint: Option<Decimal>,
129        maker_fee: Option<Decimal>,
130        taker_fee: Option<Decimal>,
131        info: Option<Params>,
132        ts_event: UnixNanos,
133        ts_init: UnixNanos,
134    ) -> CorrectnessResult<Self> {
135        check_equal_u8(
136            price_precision,
137            price_increment.precision,
138            stringify!(price_precision),
139            stringify!(price_increment.precision),
140        )?;
141        check_equal_u8(
142            size_precision,
143            size_increment.precision,
144            stringify!(size_precision),
145            stringify!(size_increment.precision),
146        )?;
147        check_positive_price(price_increment, stringify!(price_increment))?;
148        check_positive_quantity(size_increment, stringify!(size_increment))?;
149
150        Ok(Self {
151            id: instrument_id,
152            raw_symbol,
153            asset_class,
154            base_currency,
155            quote_currency,
156            price_precision,
157            size_precision,
158            price_increment,
159            size_increment,
160            lot_size,
161            max_quantity,
162            min_quantity,
163            max_notional,
164            min_notional,
165            max_price,
166            min_price,
167            margin_init: margin_init.unwrap_or_default(),
168            margin_maint: margin_maint.unwrap_or_default(),
169            maker_fee: maker_fee.unwrap_or_default(),
170            taker_fee: taker_fee.unwrap_or_default(),
171            info,
172            ts_event,
173            ts_init,
174        })
175    }
176
177    /// Creates a new [`Cfd`] instance.
178    ///
179    /// # Panics
180    ///
181    /// Panics if any parameter is invalid (see `new_checked`).
182    #[expect(clippy::too_many_arguments)]
183    #[must_use]
184    pub fn new(
185        instrument_id: InstrumentId,
186        raw_symbol: Symbol,
187        asset_class: AssetClass,
188        base_currency: Option<Currency>,
189        quote_currency: Currency,
190        price_precision: u8,
191        size_precision: u8,
192        price_increment: Price,
193        size_increment: Quantity,
194        lot_size: Option<Quantity>,
195        max_quantity: Option<Quantity>,
196        min_quantity: Option<Quantity>,
197        max_notional: Option<Money>,
198        min_notional: Option<Money>,
199        max_price: Option<Price>,
200        min_price: Option<Price>,
201        margin_init: Option<Decimal>,
202        margin_maint: Option<Decimal>,
203        maker_fee: Option<Decimal>,
204        taker_fee: Option<Decimal>,
205        info: Option<Params>,
206        ts_event: UnixNanos,
207        ts_init: UnixNanos,
208    ) -> Self {
209        Self::new_checked(
210            instrument_id,
211            raw_symbol,
212            asset_class,
213            base_currency,
214            quote_currency,
215            price_precision,
216            size_precision,
217            price_increment,
218            size_increment,
219            lot_size,
220            max_quantity,
221            min_quantity,
222            max_notional,
223            min_notional,
224            max_price,
225            min_price,
226            margin_init,
227            margin_maint,
228            maker_fee,
229            taker_fee,
230            info,
231            ts_event,
232            ts_init,
233        )
234        .expect_display(FAILED)
235    }
236}
237
238impl PartialEq<Self> for Cfd {
239    fn eq(&self, other: &Self) -> bool {
240        self.id == other.id
241    }
242}
243
244impl Eq for Cfd {}
245
246impl Hash for Cfd {
247    fn hash<H: Hasher>(&self, state: &mut H) {
248        self.id.hash(state);
249    }
250}
251
252impl Instrument for Cfd {
253    fn into_any(self) -> InstrumentAny {
254        InstrumentAny::Cfd(self)
255    }
256
257    fn id(&self) -> InstrumentId {
258        self.id
259    }
260
261    fn raw_symbol(&self) -> Symbol {
262        self.raw_symbol
263    }
264
265    fn asset_class(&self) -> AssetClass {
266        self.asset_class
267    }
268
269    fn instrument_class(&self) -> InstrumentClass {
270        InstrumentClass::Cfd
271    }
272
273    fn underlying(&self) -> Option<Ustr> {
274        None
275    }
276
277    fn base_currency(&self) -> Option<Currency> {
278        self.base_currency
279    }
280
281    fn quote_currency(&self) -> Currency {
282        self.quote_currency
283    }
284
285    fn settlement_currency(&self) -> Currency {
286        self.quote_currency
287    }
288
289    fn isin(&self) -> Option<Ustr> {
290        None
291    }
292
293    fn option_kind(&self) -> Option<OptionKind> {
294        None
295    }
296
297    fn exchange(&self) -> Option<Ustr> {
298        None
299    }
300
301    fn strike_price(&self) -> Option<Price> {
302        None
303    }
304
305    fn activation_ns(&self) -> Option<UnixNanos> {
306        None
307    }
308
309    fn expiration_ns(&self) -> Option<UnixNanos> {
310        None
311    }
312
313    fn is_inverse(&self) -> bool {
314        false
315    }
316
317    fn price_precision(&self) -> u8 {
318        self.price_precision
319    }
320
321    fn size_precision(&self) -> u8 {
322        self.size_precision
323    }
324
325    fn price_increment(&self) -> Price {
326        self.price_increment
327    }
328
329    fn size_increment(&self) -> Quantity {
330        self.size_increment
331    }
332
333    fn multiplier(&self) -> Quantity {
334        Quantity::from(1)
335    }
336
337    fn lot_size(&self) -> Option<Quantity> {
338        self.lot_size
339    }
340
341    fn max_quantity(&self) -> Option<Quantity> {
342        self.max_quantity
343    }
344
345    fn min_quantity(&self) -> Option<Quantity> {
346        self.min_quantity
347    }
348
349    fn max_notional(&self) -> Option<Money> {
350        self.max_notional
351    }
352
353    fn min_notional(&self) -> Option<Money> {
354        self.min_notional
355    }
356
357    fn max_price(&self) -> Option<Price> {
358        self.max_price
359    }
360
361    fn min_price(&self) -> Option<Price> {
362        self.min_price
363    }
364
365    fn margin_init(&self) -> Decimal {
366        self.margin_init
367    }
368
369    fn margin_maint(&self) -> Decimal {
370        self.margin_maint
371    }
372
373    fn maker_fee(&self) -> Decimal {
374        self.maker_fee
375    }
376
377    fn taker_fee(&self) -> Decimal {
378        self.taker_fee
379    }
380
381    fn ts_event(&self) -> UnixNanos {
382        self.ts_event
383    }
384
385    fn ts_init(&self) -> UnixNanos {
386        self.ts_init
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use rstest::rstest;
393
394    use crate::{
395        enums::{AssetClass, InstrumentClass},
396        identifiers::{InstrumentId, Symbol},
397        instruments::{Cfd, Instrument, stubs::*},
398        types::{Currency, Price, Quantity},
399    };
400
401    #[rstest]
402    fn test_trait_accessors(cfd_gold: Cfd) {
403        assert_eq!(cfd_gold.id(), InstrumentId::from("GOLD-CFD.SIM"));
404        assert_eq!(cfd_gold.asset_class(), AssetClass::Commodity);
405        assert_eq!(cfd_gold.instrument_class(), InstrumentClass::Cfd);
406        assert_eq!(cfd_gold.quote_currency(), Currency::USD());
407        assert!(!cfd_gold.is_inverse());
408        assert_eq!(cfd_gold.price_precision(), 2);
409        assert_eq!(cfd_gold.size_precision(), 0);
410    }
411
412    #[rstest]
413    fn test_new_checked_price_precision_mismatch() {
414        let result = Cfd::new_checked(
415            InstrumentId::from("TEST.SIM"),
416            Symbol::from("TEST"),
417            AssetClass::Commodity,
418            None,
419            Currency::USD(),
420            4, // mismatch
421            0,
422            Price::from("0.01"),
423            Quantity::from("1"),
424            None,
425            None,
426            None,
427            None,
428            None,
429            None,
430            None,
431            None,
432            None,
433            None,
434            None,
435            None,
436            0.into(),
437            0.into(),
438        );
439        assert!(result.is_err());
440    }
441
442    #[rstest]
443    fn test_serialization_roundtrip(cfd_gold: Cfd) {
444        let json = serde_json::to_string(&cfd_gold).unwrap();
445        let deserialized: Cfd = serde_json::from_str(&json).unwrap();
446        assert_eq!(cfd_gold, deserialized);
447    }
448}