1use 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#[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 pub id: InstrumentId,
54 pub raw_symbol: Symbol,
56 pub asset_class: AssetClass,
58 pub base_currency: Option<Currency>,
60 pub quote_currency: Currency,
62 pub price_precision: u8,
64 pub size_precision: u8,
66 pub price_increment: Price,
68 pub size_increment: Quantity,
70 pub margin_init: Decimal,
72 pub margin_maint: Decimal,
74 pub maker_fee: Decimal,
76 pub taker_fee: Decimal,
78 pub lot_size: Option<Quantity>,
80 pub max_quantity: Option<Quantity>,
82 pub min_quantity: Option<Quantity>,
84 pub max_notional: Option<Money>,
86 pub min_notional: Option<Money>,
88 pub max_price: Option<Price>,
90 pub min_price: Option<Price>,
92 pub tick_scheme: Option<Ustr>,
94 pub info: Option<Params>,
96 pub ts_event: UnixNanos,
98 pub ts_init: UnixNanos,
100}
101
102#[bon::bon]
103impl Cfd {
104 #[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 #[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 #[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, 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}