1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessResult, 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.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 max_quantity: Option<Quantity>,
89 pub min_quantity: Option<Quantity>,
91 pub max_price: Option<Price>,
93 pub min_price: Option<Price>,
95 pub tick_scheme: Option<Ustr>,
97 pub info: Option<Params>,
99 pub ts_event: UnixNanos,
101 pub ts_init: UnixNanos,
103}
104
105#[bon::bon]
106impl FuturesSpread {
107 #[expect(clippy::too_many_arguments)]
108 fn new_checked(
109 instrument_id: InstrumentId,
110 raw_symbol: Symbol,
111 asset_class: AssetClass,
112 exchange: Option<Ustr>,
113 underlying: Ustr,
114 strategy_type: Ustr,
115 activation_ns: UnixNanos,
116 expiration_ns: UnixNanos,
117 currency: Currency,
118 price_precision: u8,
119 price_increment: Price,
120 multiplier: Quantity,
121 lot_size: Quantity,
122 max_quantity: Option<Quantity>,
123 min_quantity: Option<Quantity>,
124 max_price: Option<Price>,
125 min_price: Option<Price>,
126 margin_init: Option<Decimal>,
127 margin_maint: Option<Decimal>,
128 tick_scheme: Option<Ustr>,
129 info: Option<Params>,
130 ts_event: UnixNanos,
131 ts_init: UnixNanos,
132 ) -> CorrectnessResult<Self> {
133 check_valid_string_ascii_optional(exchange, stringify!(exchange))?;
134 check_valid_string_ascii(strategy_type, stringify!(strategy_type))?;
135 check_equal_u8(
136 price_precision,
137 price_increment.precision,
138 stringify!(price_precision),
139 stringify!(price_increment.precision),
140 )?;
141 check_positive_price(price_increment, stringify!(price_increment))?;
142 check_tick_scheme(tick_scheme)?;
143 check_positive_quantity(multiplier, stringify!(multiplier))?;
144 check_positive_quantity(lot_size, stringify!(lot_size))?;
145
146 Ok(Self {
147 id: instrument_id,
148 raw_symbol,
149 asset_class,
150 exchange,
151 underlying,
152 strategy_type,
153 activation_ns,
154 expiration_ns,
155 currency,
156 price_precision,
157 price_increment,
158 size_precision: 0,
159 size_increment: Quantity::from("1"),
160 multiplier,
161 lot_size,
162 margin_init: margin_init.unwrap_or_default(),
163 margin_maint: margin_maint.unwrap_or_default(),
164 max_quantity,
165 min_quantity: Some(min_quantity.unwrap_or(1.into())),
166 max_price,
167 min_price,
168 tick_scheme,
169 info,
170 ts_event,
171 ts_init,
172 })
173 }
174
175 #[builder(start_fn = builder, finish_fn = build)]
184 pub fn build_checked(
185 instrument_id: InstrumentId,
186 raw_symbol: Symbol,
187 asset_class: AssetClass,
188 exchange: Option<Ustr>,
189 underlying: Ustr,
190 strategy_type: Ustr,
191 activation_ns: UnixNanos,
192 expiration_ns: UnixNanos,
193 currency: Currency,
194 price_precision: u8,
195 price_increment: Price,
196 multiplier: Quantity,
197 lot_size: Quantity,
198 max_quantity: Option<Quantity>,
199 min_quantity: Option<Quantity>,
200 max_price: Option<Price>,
201 min_price: Option<Price>,
202 margin_init: Option<Decimal>,
203 margin_maint: Option<Decimal>,
204 tick_scheme: Option<Ustr>,
205 info: Option<Params>,
206 ts_event: UnixNanos,
207 ts_init: UnixNanos,
208 ) -> CorrectnessResult<Self> {
209 Self::new_checked(
210 instrument_id,
211 raw_symbol,
212 asset_class,
213 exchange,
214 underlying,
215 strategy_type,
216 activation_ns,
217 expiration_ns,
218 currency,
219 price_precision,
220 price_increment,
221 multiplier,
222 lot_size,
223 max_quantity,
224 min_quantity,
225 max_price,
226 min_price,
227 margin_init,
228 margin_maint,
229 tick_scheme,
230 info,
231 ts_event,
232 ts_init,
233 )
234 }
235}
236
237impl PartialEq<Self> for FuturesSpread {
238 fn eq(&self, other: &Self) -> bool {
239 self.id == other.id
240 }
241}
242
243impl Eq for FuturesSpread {}
244
245impl Hash for FuturesSpread {
246 fn hash<H: Hasher>(&self, state: &mut H) {
247 self.id.hash(state);
248 }
249}
250
251impl Instrument for FuturesSpread {
252 fn into_any(self) -> InstrumentAny {
253 InstrumentAny::FuturesSpread(self)
254 }
255
256 fn id(&self) -> InstrumentId {
257 self.id
258 }
259
260 fn raw_symbol(&self) -> Symbol {
261 self.raw_symbol
262 }
263
264 fn asset_class(&self) -> AssetClass {
265 self.asset_class
266 }
267
268 fn instrument_class(&self) -> InstrumentClass {
269 InstrumentClass::FuturesSpread
270 }
271 fn underlying(&self) -> Option<Ustr> {
272 Some(self.underlying)
273 }
274
275 fn base_currency(&self) -> Option<Currency> {
276 None
277 }
278
279 fn quote_currency(&self) -> Currency {
280 self.currency
281 }
282
283 fn settlement_currency(&self) -> Currency {
284 self.currency
285 }
286
287 fn isin(&self) -> Option<Ustr> {
288 None
289 }
290
291 fn option_kind(&self) -> Option<OptionKind> {
292 None
293 }
294
295 fn exchange(&self) -> Option<Ustr> {
296 self.exchange
297 }
298
299 fn strike_price(&self) -> Option<Price> {
300 None
301 }
302
303 fn strategy_type(&self) -> Option<Ustr> {
304 Some(self.strategy_type)
305 }
306
307 fn activation_ns(&self) -> Option<UnixNanos> {
308 Some(self.activation_ns)
309 }
310
311 fn expiration_ns(&self) -> Option<UnixNanos> {
312 Some(self.expiration_ns)
313 }
314
315 fn is_inverse(&self) -> bool {
316 false
317 }
318
319 fn price_precision(&self) -> u8 {
320 self.price_precision
321 }
322
323 fn size_precision(&self) -> u8 {
324 0 }
326
327 fn price_increment(&self) -> Price {
328 self.price_increment
329 }
330
331 fn size_increment(&self) -> Quantity {
332 Quantity::from(1)
333 }
334
335 fn multiplier(&self) -> Quantity {
336 self.multiplier
337 }
338
339 fn lot_size(&self) -> Option<Quantity> {
340 Some(self.lot_size)
341 }
342
343 fn max_quantity(&self) -> Option<Quantity> {
344 self.max_quantity
345 }
346
347 fn min_quantity(&self) -> Option<Quantity> {
348 self.min_quantity
349 }
350
351 fn max_notional(&self) -> Option<Money> {
352 None
353 }
354
355 fn min_notional(&self) -> Option<Money> {
356 None
357 }
358
359 fn max_price(&self) -> Option<Price> {
360 self.max_price
361 }
362
363 fn min_price(&self) -> Option<Price> {
364 self.min_price
365 }
366
367 fn tick_scheme(&self) -> Option<Ustr> {
368 self.tick_scheme
369 }
370
371 fn info(&self) -> Option<&Params> {
372 self.info.as_ref()
373 }
374
375 fn ts_event(&self) -> UnixNanos {
376 self.ts_event
377 }
378
379 fn ts_init(&self) -> UnixNanos {
380 self.ts_init
381 }
382
383 fn margin_init(&self) -> Decimal {
384 self.margin_init
385 }
386
387 fn margin_maint(&self) -> Decimal {
388 self.margin_maint
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use rstest::rstest;
395 use rust_decimal_macros::dec;
396 use ustr::Ustr;
397
398 use crate::{
399 enums::{AssetClass, InstrumentClass},
400 identifiers::{InstrumentId, Symbol},
401 instruments::{FuturesSpread, Instrument, stubs::*},
402 types::{Currency, Price, Quantity},
403 };
404
405 #[rstest]
406 fn test_trait_accessors(futures_spread_es: FuturesSpread) {
407 assert_eq!(futures_spread_es.id(), InstrumentId::from("ESM4-ESU4.GLBX"));
408 assert_eq!(futures_spread_es.asset_class(), AssetClass::Index);
409 assert_eq!(
410 futures_spread_es.instrument_class(),
411 InstrumentClass::FuturesSpread
412 );
413 assert_eq!(futures_spread_es.quote_currency(), Currency::USD());
414 assert!(!futures_spread_es.is_inverse());
415 assert_eq!(futures_spread_es.exchange(), Some(Ustr::from("XCME")));
416 assert_eq!(futures_spread_es.size_precision(), 0);
417 assert_eq!(futures_spread_es.size_increment(), Quantity::from("1"));
418 assert_eq!(futures_spread_es.min_quantity(), Some(Quantity::from("1")));
419 assert!(futures_spread_es.activation_ns().is_some());
420 assert!(futures_spread_es.expiration_ns().is_some());
421 }
422
423 #[rstest]
424 fn test_new_checked_price_precision_mismatch() {
425 let result = FuturesSpread::new_checked(
426 InstrumentId::from("TEST.GLBX"),
427 Symbol::from("TEST"),
428 AssetClass::Index,
429 Some(Ustr::from("XCME")),
430 Ustr::from("ES"),
431 Ustr::from("EQ"),
432 0.into(),
433 0.into(),
434 Currency::USD(),
435 4, Price::from("0.01"),
437 Quantity::from(1),
438 Quantity::from(1),
439 None,
440 None,
441 None,
442 None,
443 None,
444 None,
445 None,
446 None,
447 0.into(),
448 0.into(),
449 );
450 assert!(result.is_err());
451 }
452
453 #[rstest]
454 fn test_new_checked_zero_multiplier() {
455 let result = FuturesSpread::new_checked(
456 InstrumentId::from("TEST.GLBX"),
457 Symbol::from("TEST"),
458 AssetClass::Index,
459 Some(Ustr::from("XCME")),
460 Ustr::from("ES"),
461 Ustr::from("EQ"),
462 0.into(),
463 0.into(),
464 Currency::USD(),
465 2,
466 Price::from("0.01"),
467 Quantity::from("0"), Quantity::from(1),
469 None,
470 None,
471 None,
472 None,
473 None,
474 None,
475 None,
476 None,
477 0.into(),
478 0.into(),
479 );
480 assert!(result.is_err());
481 }
482
483 #[rstest]
484 fn test_serialization_roundtrip(futures_spread_es: FuturesSpread) {
485 let json = serde_json::to_string(&futures_spread_es).unwrap();
486 let deserialized: FuturesSpread = serde_json::from_str(&json).unwrap();
487 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
488 }
489
490 #[rstest]
491 fn test_builder_matches_new_checked() {
492 let positional = FuturesSpread::new_checked(
493 InstrumentId::from("ESM4-ESU4.GLBX"),
494 Symbol::from("ESM4-ESU4"),
495 AssetClass::Index,
496 Some(Ustr::from("XCME")),
497 Ustr::from("ES"),
498 Ustr::from("EQ"),
499 1_000.into(),
500 2_000.into(),
501 Currency::USD(),
502 2,
503 Price::from("0.01"),
504 Quantity::from(50),
505 Quantity::from(10),
506 Some(Quantity::from("10000")),
507 Some(Quantity::from("5")),
508 Some(Price::from("9999.99")),
509 Some(Price::from("0.01")),
510 Some(dec!(0.01)),
511 Some(dec!(0.02)),
512 None,
513 None,
514 1.into(),
515 2.into(),
516 )
517 .unwrap();
518
519 let built = FuturesSpread::builder()
520 .instrument_id(InstrumentId::from("ESM4-ESU4.GLBX"))
521 .raw_symbol(Symbol::from("ESM4-ESU4"))
522 .asset_class(AssetClass::Index)
523 .exchange(Ustr::from("XCME"))
524 .underlying(Ustr::from("ES"))
525 .strategy_type(Ustr::from("EQ"))
526 .activation_ns(1_000.into())
527 .expiration_ns(2_000.into())
528 .currency(Currency::USD())
529 .price_precision(2)
530 .price_increment(Price::from("0.01"))
531 .multiplier(Quantity::from(50))
532 .lot_size(Quantity::from(10))
533 .max_quantity(Quantity::from("10000"))
534 .min_quantity(Quantity::from("5"))
535 .max_price(Price::from("9999.99"))
536 .min_price(Price::from("0.01"))
537 .margin_init(dec!(0.01))
538 .margin_maint(dec!(0.02))
539 .ts_event(1.into())
540 .ts_init(2.into())
541 .build()
542 .unwrap();
543
544 assert_eq!(
545 serde_json::to_value(&positional).unwrap(),
546 serde_json::to_value(&built).unwrap(),
547 );
548 }
549}