1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8, check_valid_string_ascii,
22 check_valid_string_ascii_optional,
23 },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
30use crate::{
31 enums::{AssetClass, InstrumentClass, OptionKind},
32 identifiers::{InstrumentId, Symbol},
33 types::{
34 currency::Currency,
35 money::Money,
36 price::{Price, check_positive_price},
37 quantity::{Quantity, check_positive_quantity},
38 },
39};
40
41#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
47)]
48#[cfg_attr(
49 feature = "python",
50 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
51)]
52pub struct FuturesContract {
53 pub id: InstrumentId,
55 pub raw_symbol: Symbol,
57 pub asset_class: AssetClass,
59 pub exchange: Option<Ustr>,
61 pub underlying: Ustr,
63 pub activation_ns: UnixNanos,
65 pub expiration_ns: UnixNanos,
67 pub currency: Currency,
69 pub price_precision: u8,
71 pub price_increment: Price,
73 pub size_increment: Quantity,
75 pub size_precision: u8,
77 pub multiplier: Quantity,
79 pub lot_size: Quantity,
81 pub margin_init: Decimal,
83 pub margin_maint: Decimal,
85 pub maker_fee: Decimal,
87 pub taker_fee: Decimal,
89 pub max_quantity: Option<Quantity>,
91 pub min_quantity: Option<Quantity>,
93 pub max_price: Option<Price>,
95 pub min_price: Option<Price>,
97 pub tick_scheme: Option<Ustr>,
99 pub info: Option<Params>,
101 pub ts_event: UnixNanos,
103 pub ts_init: UnixNanos,
105}
106
107#[bon::bon]
108impl FuturesContract {
109 #[expect(clippy::too_many_arguments)]
119 pub fn new_checked(
120 instrument_id: InstrumentId,
121 raw_symbol: Symbol,
122 asset_class: AssetClass,
123 exchange: Option<Ustr>,
124 underlying: Ustr,
125 activation_ns: UnixNanos,
126 expiration_ns: UnixNanos,
127 currency: Currency,
128 price_precision: u8,
129 price_increment: Price,
130 multiplier: Quantity,
131 lot_size: Quantity,
132 max_quantity: Option<Quantity>,
133 min_quantity: Option<Quantity>,
134 max_price: Option<Price>,
135 min_price: Option<Price>,
136 margin_init: Option<Decimal>,
137 margin_maint: Option<Decimal>,
138 maker_fee: Option<Decimal>,
139 taker_fee: Option<Decimal>,
140 tick_scheme: Option<Ustr>,
141 info: Option<Params>,
142 ts_event: UnixNanos,
143 ts_init: UnixNanos,
144 ) -> CorrectnessResult<Self> {
145 check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
146 check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
147 check_equal_u8(
148 price_precision,
149 price_increment.precision,
150 stringify!(price_precision),
151 stringify!(price_increment.precision),
152 )?;
153 check_positive_price(price_increment, stringify!(price_increment))?;
154 check_tick_scheme(tick_scheme)?;
155 check_positive_quantity(multiplier, stringify!(multiplier))?;
156 check_positive_quantity(lot_size, stringify!(lot_size))?;
157
158 Ok(Self {
159 id: instrument_id,
160 raw_symbol,
161 asset_class,
162 exchange,
163 underlying,
164 activation_ns,
165 expiration_ns,
166 currency,
167 price_precision,
168 price_increment,
169 size_precision: 0,
170 size_increment: Quantity::from(1),
171 multiplier,
172 lot_size,
173 max_quantity,
174 min_quantity: Some(min_quantity.unwrap_or(1.into())),
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 exchange: Option<Ustr>,
200 underlying: Ustr,
201 activation_ns: UnixNanos,
202 expiration_ns: UnixNanos,
203 currency: Currency,
204 price_precision: u8,
205 price_increment: Price,
206 multiplier: Quantity,
207 lot_size: Quantity,
208 max_quantity: Option<Quantity>,
209 min_quantity: Option<Quantity>,
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 exchange,
226 underlying,
227 activation_ns,
228 expiration_ns,
229 currency,
230 price_precision,
231 price_increment,
232 multiplier,
233 lot_size,
234 max_quantity,
235 min_quantity,
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 exchange: Option<Ustr>,
265 underlying: Ustr,
266 activation_ns: UnixNanos,
267 expiration_ns: UnixNanos,
268 currency: Currency,
269 price_precision: u8,
270 price_increment: Price,
271 multiplier: Quantity,
272 lot_size: Quantity,
273 max_quantity: Option<Quantity>,
274 min_quantity: Option<Quantity>,
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 exchange,
291 underlying,
292 activation_ns,
293 expiration_ns,
294 currency,
295 price_precision,
296 price_increment,
297 multiplier,
298 lot_size,
299 max_quantity,
300 min_quantity,
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 FuturesContract {
316 fn eq(&self, other: &Self) -> bool {
317 self.id == other.id
318 }
319}
320
321impl Eq for FuturesContract {}
322
323impl Hash for FuturesContract {
324 fn hash<H: Hasher>(&self, state: &mut H) {
325 self.id.hash(state);
326 }
327}
328
329impl Instrument for FuturesContract {
330 fn tick_scheme(&self) -> Option<Ustr> {
331 self.tick_scheme
332 }
333 fn into_any(self) -> InstrumentAny {
334 InstrumentAny::FuturesContract(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::Future
351 }
352 fn underlying(&self) -> Option<Ustr> {
353 Some(self.underlying)
354 }
355
356 fn base_currency(&self) -> Option<Currency> {
357 None
358 }
359
360 fn quote_currency(&self) -> Currency {
361 self.currency
362 }
363
364 fn settlement_currency(&self) -> Currency {
365 self.currency
366 }
367
368 fn isin(&self) -> Option<Ustr> {
369 None
370 }
371
372 fn option_kind(&self) -> Option<OptionKind> {
373 None
374 }
375
376 fn exchange(&self) -> Option<Ustr> {
377 self.exchange
378 }
379
380 fn strike_price(&self) -> Option<Price> {
381 None
382 }
383
384 fn activation_ns(&self) -> Option<UnixNanos> {
385 Some(self.activation_ns)
386 }
387
388 fn expiration_ns(&self) -> Option<UnixNanos> {
389 Some(self.expiration_ns)
390 }
391
392 fn is_inverse(&self) -> bool {
393 false
394 }
395
396 fn price_precision(&self) -> u8 {
397 self.price_precision
398 }
399
400 fn size_precision(&self) -> u8 {
401 0
402 }
403
404 fn price_increment(&self) -> Price {
405 self.price_increment
406 }
407
408 fn size_increment(&self) -> Quantity {
409 Quantity::from(1)
410 }
411
412 fn multiplier(&self) -> Quantity {
413 self.multiplier
414 }
415
416 fn lot_size(&self) -> Option<Quantity> {
417 Some(self.lot_size)
418 }
419
420 fn max_quantity(&self) -> Option<Quantity> {
421 self.max_quantity
422 }
423
424 fn min_quantity(&self) -> Option<Quantity> {
425 self.min_quantity
426 }
427
428 fn max_notional(&self) -> Option<Money> {
429 None
430 }
431
432 fn min_notional(&self) -> Option<Money> {
433 None
434 }
435
436 fn max_price(&self) -> Option<Price> {
437 self.max_price
438 }
439
440 fn min_price(&self) -> Option<Price> {
441 self.min_price
442 }
443
444 fn ts_event(&self) -> UnixNanos {
445 self.ts_event
446 }
447
448 fn ts_init(&self) -> UnixNanos {
449 self.ts_init
450 }
451
452 fn margin_init(&self) -> Decimal {
453 self.margin_init
454 }
455
456 fn margin_maint(&self) -> Decimal {
457 self.margin_maint
458 }
459
460 fn maker_fee(&self) -> Decimal {
461 self.maker_fee
462 }
463
464 fn taker_fee(&self) -> Decimal {
465 self.taker_fee
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use rstest::rstest;
472 use rust_decimal_macros::dec;
473 use ustr::Ustr;
474
475 use crate::{
476 enums::{AssetClass, InstrumentClass},
477 identifiers::{InstrumentId, Symbol},
478 instruments::{FuturesContract, Instrument, stubs::*},
479 types::{Currency, Price, Quantity},
480 };
481
482 #[rstest]
483 fn test_trait_accessors() {
484 let inst = futures_contract_es(None, None);
485 assert_eq!(inst.id(), InstrumentId::from("ESZ21.GLBX"));
486 assert_eq!(inst.raw_symbol(), Symbol::from("ESZ21"));
487 assert_eq!(inst.asset_class(), AssetClass::Index);
488 assert_eq!(inst.instrument_class(), InstrumentClass::Future);
489 assert_eq!(inst.quote_currency(), Currency::USD());
490 assert!(!inst.is_inverse());
491 assert_eq!(inst.price_precision(), 2);
492 assert_eq!(inst.size_precision(), 0);
493 assert_eq!(inst.price_increment(), Price::from("0.01"));
494 assert_eq!(inst.size_increment(), Quantity::from("1"));
495 assert_eq!(inst.multiplier(), Quantity::from("1"));
496 assert_eq!(inst.lot_size(), Some(Quantity::from("1")));
497 assert_eq!(inst.underlying(), Some(Ustr::from("ES")));
498 assert_eq!(inst.exchange(), Some(Ustr::from("XCME")));
499 assert!(inst.activation_ns().is_some());
500 assert!(inst.expiration_ns().is_some());
501 assert_eq!(inst.min_quantity(), Some(Quantity::from("1")));
502 }
503
504 #[rstest]
505 fn test_new_checked_price_precision_mismatch() {
506 let result = FuturesContract::new_checked(
507 InstrumentId::from("ESZ21.GLBX"),
508 Symbol::from("ESZ21"),
509 AssetClass::Index,
510 Some(Ustr::from("XCME")),
511 Ustr::from("ES"),
512 0.into(),
513 0.into(),
514 Currency::USD(),
515 4, Price::from("0.01"),
517 Quantity::from(1),
518 Quantity::from(1),
519 None,
520 None,
521 None,
522 None,
523 None,
524 None,
525 None,
526 None,
527 None,
528 None,
529 0.into(),
530 0.into(),
531 );
532 assert!(result.is_err());
533 }
534
535 #[rstest]
536 fn test_new_checked_zero_multiplier() {
537 let result = FuturesContract::new_checked(
538 InstrumentId::from("ESZ21.GLBX"),
539 Symbol::from("ESZ21"),
540 AssetClass::Index,
541 Some(Ustr::from("XCME")),
542 Ustr::from("ES"),
543 0.into(),
544 0.into(),
545 Currency::USD(),
546 2,
547 Price::from("0.01"),
548 Quantity::from("0"), Quantity::from(1),
550 None,
551 None,
552 None,
553 None,
554 None,
555 None,
556 None,
557 None,
558 None,
559 None,
560 0.into(),
561 0.into(),
562 );
563 assert!(result.is_err());
564 }
565
566 #[rstest]
567 fn test_new_checked_zero_lot_size() {
568 let result = FuturesContract::new_checked(
569 InstrumentId::from("ESZ21.GLBX"),
570 Symbol::from("ESZ21"),
571 AssetClass::Index,
572 Some(Ustr::from("XCME")),
573 Ustr::from("ES"),
574 0.into(),
575 0.into(),
576 Currency::USD(),
577 2,
578 Price::from("0.01"),
579 Quantity::from(1),
580 Quantity::from("0"), None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 None,
589 None,
590 None,
591 0.into(),
592 0.into(),
593 );
594 assert!(result.is_err());
595 }
596
597 #[rstest]
598 fn test_serialization_roundtrip() {
599 let inst = futures_contract_es(None, None);
600 let json = serde_json::to_string(&inst).unwrap();
601 let deserialized: FuturesContract = serde_json::from_str(&json).unwrap();
602 assert_eq!(inst, deserialized);
603 }
604
605 #[rstest]
606 fn test_builder_matches_new_checked() {
607 let positional = FuturesContract::new_checked(
608 InstrumentId::from("ESZ21.GLBX"),
609 Symbol::from("ESZ21"),
610 AssetClass::Index,
611 Some(Ustr::from("XCME")),
612 Ustr::from("ES"),
613 1_000.into(),
614 2_000.into(),
615 Currency::USD(),
616 2,
617 Price::from("0.01"),
618 Quantity::from(50),
619 Quantity::from(10),
620 Some(Quantity::from("10000")),
621 Some(Quantity::from("5")),
622 Some(Price::from("9999.99")),
623 Some(Price::from("0.01")),
624 Some(dec!(0.01)),
625 Some(dec!(0.02)),
626 Some(dec!(0.0002)),
627 Some(dec!(0.0004)),
628 None,
629 None,
630 1.into(),
631 2.into(),
632 )
633 .unwrap();
634
635 let built = FuturesContract::builder()
636 .instrument_id(InstrumentId::from("ESZ21.GLBX"))
637 .raw_symbol(Symbol::from("ESZ21"))
638 .asset_class(AssetClass::Index)
639 .exchange(Ustr::from("XCME"))
640 .underlying(Ustr::from("ES"))
641 .activation_ns(1_000.into())
642 .expiration_ns(2_000.into())
643 .currency(Currency::USD())
644 .price_precision(2)
645 .price_increment(Price::from("0.01"))
646 .multiplier(Quantity::from(50))
647 .lot_size(Quantity::from(10))
648 .max_quantity(Quantity::from("10000"))
649 .min_quantity(Quantity::from("5"))
650 .max_price(Price::from("9999.99"))
651 .min_price(Price::from("0.01"))
652 .margin_init(dec!(0.01))
653 .margin_maint(dec!(0.02))
654 .maker_fee(dec!(0.0002))
655 .taker_fee(dec!(0.0004))
656 .ts_event(1.into())
657 .ts_init(2.into())
658 .build()
659 .unwrap();
660
661 assert_eq!(
662 serde_json::to_value(&positional).unwrap(),
663 serde_json::to_value(&built).unwrap(),
664 );
665 }
666}