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.as_str(), 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 tick_scheme(&self) -> Option<Ustr> {
293 self.tick_scheme
294 }
295 fn into_any(self) -> InstrumentAny {
296 InstrumentAny::CryptoFuturesSpread(self)
297 }
298
299 fn id(&self) -> InstrumentId {
300 self.id
301 }
302
303 fn raw_symbol(&self) -> Symbol {
304 self.raw_symbol
305 }
306
307 fn asset_class(&self) -> AssetClass {
308 AssetClass::Cryptocurrency
309 }
310
311 fn instrument_class(&self) -> InstrumentClass {
312 InstrumentClass::FuturesSpread
313 }
314
315 fn underlying(&self) -> Option<Ustr> {
316 Some(self.underlying.code)
317 }
318
319 fn base_currency(&self) -> Option<Currency> {
320 Some(self.underlying)
321 }
322
323 fn quote_currency(&self) -> Currency {
324 self.quote_currency
325 }
326
327 fn settlement_currency(&self) -> Currency {
328 self.settlement_currency
329 }
330
331 fn isin(&self) -> Option<Ustr> {
332 None
333 }
334
335 fn exchange(&self) -> Option<Ustr> {
336 None
337 }
338
339 fn option_kind(&self) -> Option<OptionKind> {
340 None
341 }
342
343 fn is_inverse(&self) -> bool {
344 self.is_inverse
345 }
346
347 fn price_precision(&self) -> u8 {
348 self.price_precision
349 }
350
351 fn size_precision(&self) -> u8 {
352 self.size_precision
353 }
354
355 fn price_increment(&self) -> Price {
356 self.price_increment
357 }
358
359 fn size_increment(&self) -> Quantity {
360 self.size_increment
361 }
362
363 fn multiplier(&self) -> Quantity {
364 self.multiplier
365 }
366
367 fn lot_size(&self) -> Option<Quantity> {
368 Some(self.lot_size)
369 }
370
371 fn max_quantity(&self) -> Option<Quantity> {
372 self.max_quantity
373 }
374
375 fn min_quantity(&self) -> Option<Quantity> {
376 self.min_quantity
377 }
378
379 fn max_price(&self) -> Option<Price> {
380 self.max_price
381 }
382
383 fn min_price(&self) -> Option<Price> {
384 self.min_price
385 }
386
387 fn ts_event(&self) -> UnixNanos {
388 self.ts_event
389 }
390
391 fn ts_init(&self) -> UnixNanos {
392 self.ts_init
393 }
394
395 fn margin_init(&self) -> Decimal {
396 self.margin_init
397 }
398
399 fn margin_maint(&self) -> Decimal {
400 self.margin_maint
401 }
402
403 fn maker_fee(&self) -> Decimal {
404 self.maker_fee
405 }
406
407 fn taker_fee(&self) -> Decimal {
408 self.taker_fee
409 }
410
411 fn strike_price(&self) -> Option<Price> {
412 None
413 }
414
415 fn strategy_type(&self) -> Option<Ustr> {
416 Some(self.strategy_type)
417 }
418
419 fn activation_ns(&self) -> Option<UnixNanos> {
420 Some(self.activation_ns)
421 }
422
423 fn expiration_ns(&self) -> Option<UnixNanos> {
424 Some(self.expiration_ns)
425 }
426
427 fn max_notional(&self) -> Option<Money> {
428 self.max_notional
429 }
430
431 fn min_notional(&self) -> Option<Money> {
432 self.min_notional
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use nautilus_core::correctness::CorrectnessResult;
439 use rstest::rstest;
440 use rust_decimal_macros::dec;
441 use ustr::Ustr;
442
443 use crate::{
444 enums::{AssetClass, InstrumentClass},
445 identifiers::{InstrumentId, Symbol},
446 instruments::{CryptoFuturesSpread, Instrument, stubs::*},
447 types::{Currency, Money, Price, Quantity},
448 };
449
450 #[rstest]
451 fn test_trait_accessors(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
452 assert_eq!(
453 crypto_futures_spread_btc_deribit.id(),
454 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT")
455 );
456 assert_eq!(
457 crypto_futures_spread_btc_deribit.asset_class(),
458 AssetClass::Cryptocurrency
459 );
460 assert_eq!(
461 crypto_futures_spread_btc_deribit.instrument_class(),
462 InstrumentClass::FuturesSpread
463 );
464 assert_eq!(
465 crypto_futures_spread_btc_deribit.quote_currency(),
466 Currency::USD()
467 );
468 assert_eq!(
469 crypto_futures_spread_btc_deribit.settlement_currency(),
470 Currency::BTC()
471 );
472 assert!(!crypto_futures_spread_btc_deribit.is_inverse());
473 assert_eq!(crypto_futures_spread_btc_deribit.price_precision(), 1);
474 assert_eq!(crypto_futures_spread_btc_deribit.size_precision(), 0);
475 assert_eq!(
476 crypto_futures_spread_btc_deribit.size_increment(),
477 Quantity::from("1")
478 );
479 assert!(crypto_futures_spread_btc_deribit.activation_ns().is_some());
480 assert!(crypto_futures_spread_btc_deribit.expiration_ns().is_some());
481 }
482
483 #[rstest]
484 fn test_new_checked_price_precision_mismatch() {
485 let result = CryptoFuturesSpread::new_checked(
486 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
487 Symbol::from("BTC-FS-TEST"),
488 Currency::BTC(),
489 Currency::USD(),
490 Currency::BTC(),
491 false,
492 ustr::Ustr::from("FS"),
493 0.into(),
494 0.into(),
495 4, 0,
497 Price::from("0.5"),
498 Quantity::from("1"),
499 None,
500 None,
501 None,
502 None,
503 None,
504 None,
505 None,
506 None,
507 None,
508 None,
509 None,
510 None,
511 None,
512 None,
513 0.into(),
514 0.into(),
515 );
516 assert!(result.is_err());
517 }
518
519 #[rstest]
520 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
521 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
522 fn test_new_checked_rejects_non_positive_sizing(
523 #[case] multiplier: Option<Quantity>,
524 #[case] lot_size: Option<Quantity>,
525 ) {
526 let result = crypto_futures_spread_result(multiplier, lot_size);
527 assert!(result.is_err());
528 }
529
530 #[rstest]
531 fn test_serialization_roundtrip(crypto_futures_spread_btc_deribit: CryptoFuturesSpread) {
532 let json = serde_json::to_string(&crypto_futures_spread_btc_deribit).unwrap();
533 let deserialized: CryptoFuturesSpread = serde_json::from_str(&json).unwrap();
534 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
535 }
536
537 #[rstest]
538 fn test_builder_matches_new_checked() {
539 let positional = CryptoFuturesSpread::new_checked(
540 InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"),
541 Symbol::from("BTC-FS-19MAY26_PERP"),
542 Currency::BTC(),
543 Currency::USD(),
544 Currency::USDC(),
545 false,
546 Ustr::from("FS"),
547 1.into(),
548 2.into(),
549 1,
550 0,
551 Price::from("0.5"),
552 Quantity::from("1"),
553 Some(Quantity::from("10")),
554 Some(Quantity::from("1")),
555 Some(Quantity::from("100")),
556 Some(Quantity::from("1")),
557 Some(Money::new(5_000_000.0, Currency::USD())),
558 Some(Money::new(10.0, Currency::USD())),
559 Some(Price::from("1000000.0")),
560 Some(Price::from("0.5")),
561 Some(dec!(0.01)),
562 Some(dec!(0.02)),
563 Some(dec!(0.0002)),
564 Some(dec!(0.0004)),
565 None,
566 None,
567 10.into(),
568 20.into(),
569 )
570 .unwrap();
571
572 let built = CryptoFuturesSpread::builder()
573 .instrument_id(InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"))
574 .raw_symbol(Symbol::from("BTC-FS-19MAY26_PERP"))
575 .underlying(Currency::BTC())
576 .quote_currency(Currency::USD())
577 .settlement_currency(Currency::USDC())
578 .is_inverse(false)
579 .strategy_type(Ustr::from("FS"))
580 .activation_ns(1.into())
581 .expiration_ns(2.into())
582 .price_precision(1)
583 .size_precision(0)
584 .price_increment(Price::from("0.5"))
585 .size_increment(Quantity::from("1"))
586 .multiplier(Quantity::from("10"))
587 .lot_size(Quantity::from("1"))
588 .max_quantity(Quantity::from("100"))
589 .min_quantity(Quantity::from("1"))
590 .max_notional(Money::new(5_000_000.0, Currency::USD()))
591 .min_notional(Money::new(10.0, Currency::USD()))
592 .max_price(Price::from("1000000.0"))
593 .min_price(Price::from("0.5"))
594 .margin_init(dec!(0.01))
595 .margin_maint(dec!(0.02))
596 .maker_fee(dec!(0.0002))
597 .taker_fee(dec!(0.0004))
598 .ts_event(10.into())
599 .ts_init(20.into())
600 .build()
601 .unwrap();
602
603 assert_eq!(
604 serde_json::to_value(&positional).unwrap(),
605 serde_json::to_value(&built).unwrap(),
606 );
607 }
608
609 fn crypto_futures_spread_result(
610 multiplier: Option<Quantity>,
611 lot_size: Option<Quantity>,
612 ) -> CorrectnessResult<CryptoFuturesSpread> {
613 CryptoFuturesSpread::new_checked(
614 InstrumentId::from("BTC-FS-TEST.DERIBIT"),
615 Symbol::from("BTC-FS-TEST"),
616 Currency::BTC(),
617 Currency::USD(),
618 Currency::BTC(),
619 false,
620 ustr::Ustr::from("FS"),
621 0.into(),
622 0.into(),
623 1,
624 0,
625 Price::from("0.5"),
626 Quantity::from("1"),
627 multiplier,
628 lot_size,
629 None,
630 None,
631 None,
632 None,
633 None,
634 None,
635 None,
636 None,
637 None,
638 None,
639 None,
640 None,
641 0.into(),
642 0.into(),
643 )
644 }
645}