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 FuturesSpread {
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 strategy_type: Ustr,
65 pub activation_ns: UnixNanos,
67 pub expiration_ns: UnixNanos,
69 pub currency: Currency,
71 pub price_precision: u8,
73 pub price_increment: Price,
75 pub size_increment: Quantity,
77 pub size_precision: u8,
79 pub multiplier: Quantity,
81 pub lot_size: Quantity,
83 pub margin_init: Decimal,
85 pub margin_maint: Decimal,
87 pub maker_fee: Decimal,
89 pub taker_fee: Decimal,
91 pub max_quantity: Option<Quantity>,
93 pub min_quantity: Option<Quantity>,
95 pub max_price: Option<Price>,
97 pub min_price: Option<Price>,
99 pub tick_scheme: Option<Ustr>,
101 pub info: Option<Params>,
103 pub ts_event: UnixNanos,
105 pub ts_init: UnixNanos,
107}
108
109#[bon::bon]
110impl FuturesSpread {
111 #[expect(clippy::too_many_arguments)]
121 pub fn new_checked(
122 instrument_id: InstrumentId,
123 raw_symbol: Symbol,
124 asset_class: AssetClass,
125 exchange: Option<Ustr>,
126 underlying: Ustr,
127 strategy_type: Ustr,
128 activation_ns: UnixNanos,
129 expiration_ns: UnixNanos,
130 currency: Currency,
131 price_precision: u8,
132 price_increment: Price,
133 multiplier: Quantity,
134 lot_size: Quantity,
135 max_quantity: Option<Quantity>,
136 min_quantity: Option<Quantity>,
137 max_price: Option<Price>,
138 min_price: Option<Price>,
139 margin_init: Option<Decimal>,
140 margin_maint: Option<Decimal>,
141 maker_fee: Option<Decimal>,
142 taker_fee: Option<Decimal>,
143 tick_scheme: Option<Ustr>,
144 info: Option<Params>,
145 ts_event: UnixNanos,
146 ts_init: UnixNanos,
147 ) -> CorrectnessResult<Self> {
148 check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
149 check_valid_string_ascii(strategy_type.as_str(), stringify!(strategy_type))?;
150 check_equal_u8(
151 price_precision,
152 price_increment.precision,
153 stringify!(price_precision),
154 stringify!(price_increment.precision),
155 )?;
156 check_positive_price(price_increment, stringify!(price_increment))?;
157 check_tick_scheme(tick_scheme)?;
158 check_positive_quantity(multiplier, stringify!(multiplier))?;
159 check_positive_quantity(lot_size, stringify!(lot_size))?;
160
161 Ok(Self {
162 id: instrument_id,
163 raw_symbol,
164 asset_class,
165 exchange,
166 underlying,
167 strategy_type,
168 activation_ns,
169 expiration_ns,
170 currency,
171 price_precision,
172 price_increment,
173 size_precision: 0,
174 size_increment: Quantity::from("1"),
175 multiplier,
176 lot_size,
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 max_quantity,
182 min_quantity: Some(min_quantity.unwrap_or(1.into())),
183 max_price,
184 min_price,
185 tick_scheme,
186 info,
187 ts_event,
188 ts_init,
189 })
190 }
191
192 #[expect(clippy::too_many_arguments)]
198 #[must_use]
199 pub fn new(
200 instrument_id: InstrumentId,
201 raw_symbol: Symbol,
202 asset_class: AssetClass,
203 exchange: Option<Ustr>,
204 underlying: Ustr,
205 strategy_type: Ustr,
206 activation_ns: UnixNanos,
207 expiration_ns: UnixNanos,
208 currency: Currency,
209 price_precision: u8,
210 price_increment: Price,
211 multiplier: Quantity,
212 lot_size: Quantity,
213 max_quantity: Option<Quantity>,
214 min_quantity: Option<Quantity>,
215 max_price: Option<Price>,
216 min_price: Option<Price>,
217 margin_init: Option<Decimal>,
218 margin_maint: Option<Decimal>,
219 maker_fee: Option<Decimal>,
220 taker_fee: Option<Decimal>,
221 tick_scheme: Option<Ustr>,
222 info: Option<Params>,
223 ts_event: UnixNanos,
224 ts_init: UnixNanos,
225 ) -> Self {
226 Self::new_checked(
227 instrument_id,
228 raw_symbol,
229 asset_class,
230 exchange,
231 underlying,
232 strategy_type,
233 activation_ns,
234 expiration_ns,
235 currency,
236 price_precision,
237 price_increment,
238 multiplier,
239 lot_size,
240 max_quantity,
241 min_quantity,
242 max_price,
243 min_price,
244 margin_init,
245 margin_maint,
246 maker_fee,
247 taker_fee,
248 tick_scheme,
249 info,
250 ts_event,
251 ts_init,
252 )
253 .expect_display(FAILED)
254 }
255
256 #[builder(start_fn = builder, finish_fn = build)]
266 pub fn build_checked(
267 instrument_id: InstrumentId,
268 raw_symbol: Symbol,
269 asset_class: AssetClass,
270 exchange: Option<Ustr>,
271 underlying: Ustr,
272 strategy_type: Ustr,
273 activation_ns: UnixNanos,
274 expiration_ns: UnixNanos,
275 currency: Currency,
276 price_precision: u8,
277 price_increment: Price,
278 multiplier: Quantity,
279 lot_size: Quantity,
280 max_quantity: Option<Quantity>,
281 min_quantity: Option<Quantity>,
282 max_price: Option<Price>,
283 min_price: Option<Price>,
284 margin_init: Option<Decimal>,
285 margin_maint: Option<Decimal>,
286 maker_fee: Option<Decimal>,
287 taker_fee: Option<Decimal>,
288 tick_scheme: Option<Ustr>,
289 info: Option<Params>,
290 ts_event: UnixNanos,
291 ts_init: UnixNanos,
292 ) -> CorrectnessResult<Self> {
293 Self::new_checked(
294 instrument_id,
295 raw_symbol,
296 asset_class,
297 exchange,
298 underlying,
299 strategy_type,
300 activation_ns,
301 expiration_ns,
302 currency,
303 price_precision,
304 price_increment,
305 multiplier,
306 lot_size,
307 max_quantity,
308 min_quantity,
309 max_price,
310 min_price,
311 margin_init,
312 margin_maint,
313 maker_fee,
314 taker_fee,
315 tick_scheme,
316 info,
317 ts_event,
318 ts_init,
319 )
320 }
321}
322
323impl PartialEq<Self> for FuturesSpread {
324 fn eq(&self, other: &Self) -> bool {
325 self.id == other.id
326 }
327}
328
329impl Eq for FuturesSpread {}
330
331impl Hash for FuturesSpread {
332 fn hash<H: Hasher>(&self, state: &mut H) {
333 self.id.hash(state);
334 }
335}
336
337impl Instrument for FuturesSpread {
338 fn tick_scheme(&self) -> Option<Ustr> {
339 self.tick_scheme
340 }
341 fn into_any(self) -> InstrumentAny {
342 InstrumentAny::FuturesSpread(self)
343 }
344
345 fn id(&self) -> InstrumentId {
346 self.id
347 }
348
349 fn raw_symbol(&self) -> Symbol {
350 self.raw_symbol
351 }
352
353 fn asset_class(&self) -> AssetClass {
354 self.asset_class
355 }
356
357 fn instrument_class(&self) -> InstrumentClass {
358 InstrumentClass::FuturesSpread
359 }
360 fn underlying(&self) -> Option<Ustr> {
361 Some(self.underlying)
362 }
363
364 fn base_currency(&self) -> Option<Currency> {
365 None
366 }
367
368 fn quote_currency(&self) -> Currency {
369 self.currency
370 }
371
372 fn settlement_currency(&self) -> Currency {
373 self.currency
374 }
375
376 fn isin(&self) -> Option<Ustr> {
377 None
378 }
379
380 fn option_kind(&self) -> Option<OptionKind> {
381 None
382 }
383
384 fn exchange(&self) -> Option<Ustr> {
385 self.exchange
386 }
387
388 fn strike_price(&self) -> Option<Price> {
389 None
390 }
391
392 fn strategy_type(&self) -> Option<Ustr> {
393 Some(self.strategy_type)
394 }
395
396 fn activation_ns(&self) -> Option<UnixNanos> {
397 Some(self.activation_ns)
398 }
399
400 fn expiration_ns(&self) -> Option<UnixNanos> {
401 Some(self.expiration_ns)
402 }
403
404 fn is_inverse(&self) -> bool {
405 false
406 }
407
408 fn price_precision(&self) -> u8 {
409 self.price_precision
410 }
411
412 fn size_precision(&self) -> u8 {
413 0 }
415
416 fn price_increment(&self) -> Price {
417 self.price_increment
418 }
419
420 fn size_increment(&self) -> Quantity {
421 Quantity::from(1)
422 }
423
424 fn multiplier(&self) -> Quantity {
425 self.multiplier
426 }
427
428 fn lot_size(&self) -> Option<Quantity> {
429 Some(self.lot_size)
430 }
431
432 fn max_quantity(&self) -> Option<Quantity> {
433 self.max_quantity
434 }
435
436 fn min_quantity(&self) -> Option<Quantity> {
437 self.min_quantity
438 }
439
440 fn max_notional(&self) -> Option<Money> {
441 None
442 }
443
444 fn min_notional(&self) -> Option<Money> {
445 None
446 }
447
448 fn max_price(&self) -> Option<Price> {
449 self.max_price
450 }
451
452 fn min_price(&self) -> Option<Price> {
453 self.min_price
454 }
455
456 fn ts_event(&self) -> UnixNanos {
457 self.ts_event
458 }
459
460 fn ts_init(&self) -> UnixNanos {
461 self.ts_init
462 }
463
464 fn margin_init(&self) -> Decimal {
465 self.margin_init
466 }
467
468 fn margin_maint(&self) -> Decimal {
469 self.margin_maint
470 }
471
472 fn maker_fee(&self) -> Decimal {
473 self.maker_fee
474 }
475
476 fn taker_fee(&self) -> Decimal {
477 self.taker_fee
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use rstest::rstest;
484 use rust_decimal_macros::dec;
485 use ustr::Ustr;
486
487 use crate::{
488 enums::{AssetClass, InstrumentClass},
489 identifiers::{InstrumentId, Symbol},
490 instruments::{FuturesSpread, Instrument, stubs::*},
491 types::{Currency, Price, Quantity},
492 };
493
494 #[rstest]
495 fn test_trait_accessors(futures_spread_es: FuturesSpread) {
496 assert_eq!(futures_spread_es.id(), InstrumentId::from("ESM4-ESU4.GLBX"));
497 assert_eq!(futures_spread_es.asset_class(), AssetClass::Index);
498 assert_eq!(
499 futures_spread_es.instrument_class(),
500 InstrumentClass::FuturesSpread
501 );
502 assert_eq!(futures_spread_es.quote_currency(), Currency::USD());
503 assert!(!futures_spread_es.is_inverse());
504 assert_eq!(futures_spread_es.exchange(), Some(Ustr::from("XCME")));
505 assert_eq!(futures_spread_es.size_precision(), 0);
506 assert_eq!(futures_spread_es.size_increment(), Quantity::from("1"));
507 assert_eq!(futures_spread_es.min_quantity(), Some(Quantity::from("1")));
508 assert!(futures_spread_es.activation_ns().is_some());
509 assert!(futures_spread_es.expiration_ns().is_some());
510 }
511
512 #[rstest]
513 fn test_new_checked_price_precision_mismatch() {
514 let result = FuturesSpread::new_checked(
515 InstrumentId::from("TEST.GLBX"),
516 Symbol::from("TEST"),
517 AssetClass::Index,
518 Some(Ustr::from("XCME")),
519 Ustr::from("ES"),
520 Ustr::from("EQ"),
521 0.into(),
522 0.into(),
523 Currency::USD(),
524 4, Price::from("0.01"),
526 Quantity::from(1),
527 Quantity::from(1),
528 None,
529 None,
530 None,
531 None,
532 None,
533 None,
534 None,
535 None,
536 None,
537 None,
538 0.into(),
539 0.into(),
540 );
541 assert!(result.is_err());
542 }
543
544 #[rstest]
545 fn test_new_checked_zero_multiplier() {
546 let result = FuturesSpread::new_checked(
547 InstrumentId::from("TEST.GLBX"),
548 Symbol::from("TEST"),
549 AssetClass::Index,
550 Some(Ustr::from("XCME")),
551 Ustr::from("ES"),
552 Ustr::from("EQ"),
553 0.into(),
554 0.into(),
555 Currency::USD(),
556 2,
557 Price::from("0.01"),
558 Quantity::from("0"), Quantity::from(1),
560 None,
561 None,
562 None,
563 None,
564 None,
565 None,
566 None,
567 None,
568 None,
569 None,
570 0.into(),
571 0.into(),
572 );
573 assert!(result.is_err());
574 }
575
576 #[rstest]
577 fn test_serialization_roundtrip(futures_spread_es: FuturesSpread) {
578 let json = serde_json::to_string(&futures_spread_es).unwrap();
579 let deserialized: FuturesSpread = serde_json::from_str(&json).unwrap();
580 assert_eq!(futures_spread_es, deserialized);
581 }
582
583 #[rstest]
584 fn test_builder_matches_new_checked() {
585 let positional = FuturesSpread::new_checked(
586 InstrumentId::from("ESM4-ESU4.GLBX"),
587 Symbol::from("ESM4-ESU4"),
588 AssetClass::Index,
589 Some(Ustr::from("XCME")),
590 Ustr::from("ES"),
591 Ustr::from("EQ"),
592 1_000.into(),
593 2_000.into(),
594 Currency::USD(),
595 2,
596 Price::from("0.01"),
597 Quantity::from(50),
598 Quantity::from(10),
599 Some(Quantity::from("10000")),
600 Some(Quantity::from("5")),
601 Some(Price::from("9999.99")),
602 Some(Price::from("0.01")),
603 Some(dec!(0.01)),
604 Some(dec!(0.02)),
605 Some(dec!(0.0002)),
606 Some(dec!(0.0004)),
607 None,
608 None,
609 1.into(),
610 2.into(),
611 )
612 .unwrap();
613
614 let built = FuturesSpread::builder()
615 .instrument_id(InstrumentId::from("ESM4-ESU4.GLBX"))
616 .raw_symbol(Symbol::from("ESM4-ESU4"))
617 .asset_class(AssetClass::Index)
618 .exchange(Ustr::from("XCME"))
619 .underlying(Ustr::from("ES"))
620 .strategy_type(Ustr::from("EQ"))
621 .activation_ns(1_000.into())
622 .expiration_ns(2_000.into())
623 .currency(Currency::USD())
624 .price_precision(2)
625 .price_increment(Price::from("0.01"))
626 .multiplier(Quantity::from(50))
627 .lot_size(Quantity::from(10))
628 .max_quantity(Quantity::from("10000"))
629 .min_quantity(Quantity::from("5"))
630 .max_price(Price::from("9999.99"))
631 .min_price(Price::from("0.01"))
632 .margin_init(dec!(0.01))
633 .margin_maint(dec!(0.02))
634 .maker_fee(dec!(0.0002))
635 .taker_fee(dec!(0.0004))
636 .ts_event(1.into())
637 .ts_init(2.into())
638 .build()
639 .unwrap();
640
641 assert_eq!(
642 serde_json::to_value(&positional).unwrap(),
643 serde_json::to_value(&built).unwrap(),
644 );
645 }
646}