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