1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{CorrectnessResult, check_equal_u8, check_valid_string_ascii},
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)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
45)]
46#[cfg_attr(
47 feature = "python",
48 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct CryptoFuturesSpread {
51 pub id: InstrumentId,
53 pub raw_symbol: Symbol,
55 pub underlying: Currency,
57 pub quote_currency: Currency,
59 pub settlement_currency: Currency,
61 pub is_inverse: bool,
63 pub strategy_type: Ustr,
65 pub activation_ns: UnixNanos,
67 pub expiration_ns: UnixNanos,
69 pub price_precision: u8,
71 pub size_precision: u8,
73 pub price_increment: Price,
75 pub size_increment: Quantity,
77 pub multiplier: Quantity,
79 pub lot_size: Quantity,
81 pub margin_init: Decimal,
83 pub margin_maint: Decimal,
85 pub max_quantity: Option<Quantity>,
87 pub min_quantity: Option<Quantity>,
89 pub max_notional: Option<Money>,
91 pub min_notional: Option<Money>,
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 CryptoFuturesSpread {
109 #[expect(clippy::too_many_arguments)]
110 fn new_checked(
111 instrument_id: InstrumentId,
112 raw_symbol: Symbol,
113 underlying: Currency,
114 quote_currency: Currency,
115 settlement_currency: Currency,
116 is_inverse: bool,
117 strategy_type: Ustr,
118 activation_ns: UnixNanos,
119 expiration_ns: UnixNanos,
120 price_precision: u8,
121 size_precision: u8,
122 price_increment: Price,
123 size_increment: Quantity,
124 multiplier: Option<Quantity>,
125 lot_size: Option<Quantity>,
126 max_quantity: Option<Quantity>,
127 min_quantity: Option<Quantity>,
128 max_notional: Option<Money>,
129 min_notional: Option<Money>,
130 max_price: Option<Price>,
131 min_price: Option<Price>,
132 margin_init: Option<Decimal>,
133 margin_maint: Option<Decimal>,
134 tick_scheme: Option<Ustr>,
135 info: Option<Params>,
136 ts_event: UnixNanos,
137 ts_init: UnixNanos,
138 ) -> CorrectnessResult<Self> {
139 check_valid_string_ascii(strategy_type, stringify!(strategy_type))?;
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(multiplier) = multiplier {
157 check_positive_quantity(multiplier, stringify!(multiplier))?;
158 }
159
160 if let Some(lot_size) = lot_size {
161 check_positive_quantity(lot_size, stringify!(lot_size))?;
162 }
163
164 Ok(Self {
165 id: instrument_id,
166 raw_symbol,
167 underlying,
168 quote_currency,
169 settlement_currency,
170 is_inverse,
171 strategy_type,
172 activation_ns,
173 expiration_ns,
174 price_precision,
175 size_precision,
176 price_increment,
177 size_increment,
178 multiplier: multiplier.unwrap_or(Quantity::from(1)),
179 lot_size: lot_size.unwrap_or(Quantity::from(1)),
180 margin_init: margin_init.unwrap_or_default(),
181 margin_maint: margin_maint.unwrap_or_default(),
182 max_quantity,
183 min_quantity,
184 max_notional,
185 min_notional,
186 max_price,
187 min_price,
188 tick_scheme,
189 info,
190 ts_event,
191 ts_init,
192 })
193 }
194
195 #[builder(start_fn = builder, finish_fn = build)]
204 pub fn build_checked(
205 instrument_id: InstrumentId,
206 raw_symbol: Symbol,
207 underlying: Currency,
208 quote_currency: Currency,
209 settlement_currency: Currency,
210 is_inverse: bool,
211 strategy_type: Ustr,
212 activation_ns: UnixNanos,
213 expiration_ns: UnixNanos,
214 price_precision: u8,
215 size_precision: u8,
216 price_increment: Price,
217 size_increment: Quantity,
218 multiplier: Option<Quantity>,
219 lot_size: Option<Quantity>,
220 max_quantity: Option<Quantity>,
221 min_quantity: Option<Quantity>,
222 max_notional: Option<Money>,
223 min_notional: Option<Money>,
224 max_price: Option<Price>,
225 min_price: Option<Price>,
226 margin_init: Option<Decimal>,
227 margin_maint: Option<Decimal>,
228 tick_scheme: Option<Ustr>,
229 info: Option<Params>,
230 ts_event: UnixNanos,
231 ts_init: UnixNanos,
232 ) -> CorrectnessResult<Self> {
233 Self::new_checked(
234 instrument_id,
235 raw_symbol,
236 underlying,
237 quote_currency,
238 settlement_currency,
239 is_inverse,
240 strategy_type,
241 activation_ns,
242 expiration_ns,
243 price_precision,
244 size_precision,
245 price_increment,
246 size_increment,
247 multiplier,
248 lot_size,
249 max_quantity,
250 min_quantity,
251 max_notional,
252 min_notional,
253 max_price,
254 min_price,
255 margin_init,
256 margin_maint,
257 tick_scheme,
258 info,
259 ts_event,
260 ts_init,
261 )
262 }
263}
264
265impl PartialEq<Self> for CryptoFuturesSpread {
266 fn eq(&self, other: &Self) -> bool {
267 self.id == other.id
268 }
269}
270
271impl Eq for CryptoFuturesSpread {}
272
273impl Hash for CryptoFuturesSpread {
274 fn hash<H: Hasher>(&self, state: &mut H) {
275 self.id.hash(state);
276 }
277}
278
279impl Instrument for CryptoFuturesSpread {
280 fn into_any(self) -> InstrumentAny {
281 InstrumentAny::CryptoFuturesSpread(self)
282 }
283
284 fn id(&self) -> InstrumentId {
285 self.id
286 }
287
288 fn raw_symbol(&self) -> Symbol {
289 self.raw_symbol
290 }
291
292 fn asset_class(&self) -> AssetClass {
293 AssetClass::Cryptocurrency
294 }
295
296 fn instrument_class(&self) -> InstrumentClass {
297 InstrumentClass::FuturesSpread
298 }
299
300 fn underlying(&self) -> Option<Ustr> {
301 Some(self.underlying.code)
302 }
303
304 fn base_currency(&self) -> Option<Currency> {
305 Some(self.underlying)
306 }
307
308 fn quote_currency(&self) -> Currency {
309 self.quote_currency
310 }
311
312 fn settlement_currency(&self) -> Currency {
313 self.settlement_currency
314 }
315
316 fn isin(&self) -> Option<Ustr> {
317 None
318 }
319
320 fn exchange(&self) -> Option<Ustr> {
321 None
322 }
323
324 fn option_kind(&self) -> Option<OptionKind> {
325 None
326 }
327
328 fn is_inverse(&self) -> bool {
329 self.is_inverse
330 }
331
332 fn price_precision(&self) -> u8 {
333 self.price_precision
334 }
335
336 fn size_precision(&self) -> u8 {
337 self.size_precision
338 }
339
340 fn price_increment(&self) -> Price {
341 self.price_increment
342 }
343
344 fn size_increment(&self) -> Quantity {
345 self.size_increment
346 }
347
348 fn multiplier(&self) -> Quantity {
349 self.multiplier
350 }
351
352 fn lot_size(&self) -> Option<Quantity> {
353 Some(self.lot_size)
354 }
355
356 fn max_quantity(&self) -> Option<Quantity> {
357 self.max_quantity
358 }
359
360 fn min_quantity(&self) -> Option<Quantity> {
361 self.min_quantity
362 }
363
364 fn max_price(&self) -> Option<Price> {
365 self.max_price
366 }
367
368 fn min_price(&self) -> Option<Price> {
369 self.min_price
370 }
371
372 fn tick_scheme(&self) -> Option<Ustr> {
373 self.tick_scheme
374 }
375
376 fn info(&self) -> Option<&Params> {
377 self.info.as_ref()
378 }
379
380 fn ts_event(&self) -> UnixNanos {
381 self.ts_event
382 }
383
384 fn ts_init(&self) -> UnixNanos {
385 self.ts_init
386 }
387
388 fn margin_init(&self) -> Decimal {
389 self.margin_init
390 }
391
392 fn margin_maint(&self) -> Decimal {
393 self.margin_maint
394 }
395
396 fn strike_price(&self) -> Option<Price> {
397 None
398 }
399
400 fn strategy_type(&self) -> Option<Ustr> {
401 Some(self.strategy_type)
402 }
403
404 fn activation_ns(&self) -> Option<UnixNanos> {
405 Some(self.activation_ns)
406 }
407
408 fn expiration_ns(&self) -> Option<UnixNanos> {
409 Some(self.expiration_ns)
410 }
411
412 fn max_notional(&self) -> Option<Money> {
413 self.max_notional
414 }
415
416 fn min_notional(&self) -> Option<Money> {
417 self.min_notional
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use nautilus_core::correctness::CorrectnessResult;
424 use rstest::rstest;
425 use rust_decimal_macros::dec;
426 use ustr::Ustr;
427
428 use crate::{
429 enums::{AssetClass, InstrumentClass},
430 identifiers::{InstrumentId, Symbol},
431 instruments::{CryptoFuturesSpread, Instrument, stubs::*},
432 types::{Currency, Money, Price, Quantity},
433 };
434
435 #[rstest]
436 fn test_trait_accessors(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
437 assert_eq!(
438 crypto_futures_spread_btc_deribit.id(),
439 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT")
440 );
441 assert_eq!(
442 crypto_futures_spread_btc_deribit.asset_class(),
443 AssetClass::Cryptocurrency
444 );
445 assert_eq!(
446 crypto_futures_spread_btc_deribit.instrument_class(),
447 InstrumentClass::FuturesSpread
448 );
449 assert_eq!(
450 crypto_futures_spread_btc_deribit.quote_currency(),
451 Currency::USD()
452 );
453 assert_eq!(
454 crypto_futures_spread_btc_deribit.settlement_currency(),
455 Currency::BTC()
456 );
457 assert!(!crypto_futures_spread_btc_deribit.is_inverse());
458 assert_eq!(crypto_futures_spread_btc_deribit.price_precision(), 1);
459 assert_eq!(crypto_futures_spread_btc_deribit.size_precision(), 0);
460 assert_eq!(
461 crypto_futures_spread_btc_deribit.size_increment(),
462 Quantity::from("1")
463 );
464 assert!(crypto_futures_spread_btc_deribit.activation_ns().is_some());
465 assert!(crypto_futures_spread_btc_deribit.expiration_ns().is_some());
466 }
467
468 #[rstest]
469 fn test_new_checked_price_precision_mismatch() {
470 let result = CryptoFuturesSpread::new_checked(
471 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
472 Symbol::from("BTC-FS-TEST"),
473 Currency::BTC(),
474 Currency::USD(),
475 Currency::BTC(),
476 false,
477 ustr::Ustr::from("FS"),
478 0.into(),
479 0.into(),
480 4, 0,
482 Price::from("0.5"),
483 Quantity::from("1"),
484 None,
485 None,
486 None,
487 None,
488 None,
489 None,
490 None,
491 None,
492 None,
493 None,
494 None,
495 None,
496 0.into(),
497 0.into(),
498 );
499 assert!(result.is_err());
500 }
501
502 #[rstest]
503 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
504 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
505 fn test_new_checked_rejects_non_positive_sizing(
506 #[case] multiplier: Option<Quantity>,
507 #[case] lot_size: Option<Quantity>,
508 ) {
509 let result = crypto_futures_spread_result(multiplier, lot_size);
510 assert!(result.is_err());
511 }
512
513 #[rstest]
514 fn test_serialization_roundtrip(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
515 let json = serde_json::to_string(&crypto_futures_spread_btc_deribit).unwrap();
516 let deserialized: CryptoFuturesSpread = serde_json::from_str(&json).unwrap();
517 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
518 }
519
520 #[rstest]
521 fn test_builder_matches_new_checked() {
522 let positional = CryptoFuturesSpread::new_checked(
523 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"),
524 Symbol::from("BTC-FS-19MAY26_PERP"),
525 Currency::BTC(),
526 Currency::USD(),
527 Currency::USDC(),
528 false,
529 Ustr::from("FS"),
530 1.into(),
531 2.into(),
532 1,
533 0,
534 Price::from("0.5"),
535 Quantity::from("1"),
536 Some(Quantity::from("10")),
537 Some(Quantity::from("1")),
538 Some(Quantity::from("100")),
539 Some(Quantity::from("1")),
540 Some(Money::new(5_000_000.0, Currency::USD())),
541 Some(Money::new(10.0, Currency::USD())),
542 Some(Price::from("1000000.0")),
543 Some(Price::from("0.5")),
544 Some(dec!(0.01)),
545 Some(dec!(0.02)),
546 None,
547 None,
548 10.into(),
549 20.into(),
550 )
551 .unwrap();
552
553 let built = CryptoFuturesSpread::builder()
554 .instrument_id(InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"))
555 .raw_symbol(Symbol::from("BTC-FS-19MAY26_PERP"))
556 .underlying(Currency::BTC())
557 .quote_currency(Currency::USD())
558 .settlement_currency(Currency::USDC())
559 .is_inverse(false)
560 .strategy_type(Ustr::from("FS"))
561 .activation_ns(1.into())
562 .expiration_ns(2.into())
563 .price_precision(1)
564 .size_precision(0)
565 .price_increment(Price::from("0.5"))
566 .size_increment(Quantity::from("1"))
567 .multiplier(Quantity::from("10"))
568 .lot_size(Quantity::from("1"))
569 .max_quantity(Quantity::from("100"))
570 .min_quantity(Quantity::from("1"))
571 .max_notional(Money::new(5_000_000.0, Currency::USD()))
572 .min_notional(Money::new(10.0, Currency::USD()))
573 .max_price(Price::from("1000000.0"))
574 .min_price(Price::from("0.5"))
575 .margin_init(dec!(0.01))
576 .margin_maint(dec!(0.02))
577 .ts_event(10.into())
578 .ts_init(20.into())
579 .build()
580 .unwrap();
581
582 assert_eq!(
583 serde_json::to_value(&positional).unwrap(),
584 serde_json::to_value(&built).unwrap(),
585 );
586 }
587
588 fn crypto_futures_spread_result(
589 multiplier: Option<Quantity>,
590 lot_size: Option<Quantity>,
591 ) -> CorrectnessResult<CryptoFuturesSpread> {
592 CryptoFuturesSpread::new_checked(
593 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
594 Symbol::from("BTC-FS-TEST"),
595 Currency::BTC(),
596 Currency::USD(),
597 Currency::BTC(),
598 false,
599 ustr::Ustr::from("FS"),
600 0.into(),
601 0.into(),
602 1,
603 0,
604 Price::from("0.5"),
605 Quantity::from("1"),
606 multiplier,
607 lot_size,
608 None,
609 None,
610 None,
611 None,
612 None,
613 None,
614 None,
615 None,
616 None,
617 None,
618 0.into(),
619 0.into(),
620 )
621 }
622}