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 CryptoOptionSpread {
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 CryptoOptionSpread {
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_notional,
191 min_notional,
192 max_quantity,
193 min_quantity,
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 CryptoOptionSpread {
278 fn eq(&self, other: &Self) -> bool {
279 self.id == other.id
280 }
281}
282
283impl Eq for CryptoOptionSpread {}
284
285impl Hash for CryptoOptionSpread {
286 fn hash<H: Hasher>(&self, state: &mut H) {
287 self.id.hash(state);
288 }
289}
290
291impl Instrument for CryptoOptionSpread {
292 fn tick_scheme(&self) -> Option<Ustr> {
293 self.tick_scheme
294 }
295 fn into_any(self) -> InstrumentAny {
296 InstrumentAny::CryptoOptionSpread(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::OptionSpread
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 is_inverse(&self) -> bool {
332 self.is_inverse
333 }
334
335 fn isin(&self) -> Option<Ustr> {
336 None
337 }
338
339 fn option_kind(&self) -> Option<OptionKind> {
340 None
341 }
342
343 fn strike_price(&self) -> Option<Price> {
344 None
345 }
346
347 fn strategy_type(&self) -> Option<Ustr> {
348 Some(self.strategy_type)
349 }
350
351 fn activation_ns(&self) -> Option<UnixNanos> {
352 Some(self.activation_ns)
353 }
354
355 fn expiration_ns(&self) -> Option<UnixNanos> {
356 Some(self.expiration_ns)
357 }
358
359 fn exchange(&self) -> Option<Ustr> {
360 None
361 }
362
363 fn price_precision(&self) -> u8 {
364 self.price_precision
365 }
366
367 fn size_precision(&self) -> u8 {
368 self.size_precision
369 }
370
371 fn price_increment(&self) -> Price {
372 self.price_increment
373 }
374
375 fn size_increment(&self) -> Quantity {
376 self.size_increment
377 }
378
379 fn multiplier(&self) -> Quantity {
380 self.multiplier
381 }
382
383 fn lot_size(&self) -> Option<Quantity> {
384 Some(self.lot_size)
385 }
386
387 fn max_quantity(&self) -> Option<Quantity> {
388 self.max_quantity
389 }
390
391 fn min_quantity(&self) -> Option<Quantity> {
392 self.min_quantity
393 }
394
395 fn max_notional(&self) -> Option<Money> {
396 self.max_notional
397 }
398
399 fn min_notional(&self) -> Option<Money> {
400 self.min_notional
401 }
402
403 fn max_price(&self) -> Option<Price> {
404 self.max_price
405 }
406
407 fn min_price(&self) -> Option<Price> {
408 self.min_price
409 }
410
411 fn ts_event(&self) -> UnixNanos {
412 self.ts_event
413 }
414
415 fn ts_init(&self) -> UnixNanos {
416 self.ts_init
417 }
418
419 fn margin_init(&self) -> Decimal {
420 self.margin_init
421 }
422
423 fn margin_maint(&self) -> Decimal {
424 self.margin_maint
425 }
426
427 fn maker_fee(&self) -> Decimal {
428 self.maker_fee
429 }
430
431 fn taker_fee(&self) -> Decimal {
432 self.taker_fee
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
442 use crate::{
443 enums::{AssetClass, InstrumentClass},
444 identifiers::{InstrumentId, Symbol},
445 instruments::{CryptoOptionSpread, Instrument, stubs::*},
446 types::{Currency, Money, Price, Quantity},
447 };
448
449 #[rstest]
450 fn test_trait_accessors(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
451 assert_eq!(
452 crypto_option_spread_btc_deribit.id(),
453 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT")
454 );
455 assert_eq!(
456 crypto_option_spread_btc_deribit.asset_class(),
457 AssetClass::Cryptocurrency
458 );
459 assert_eq!(
460 crypto_option_spread_btc_deribit.instrument_class(),
461 InstrumentClass::OptionSpread
462 );
463 assert_eq!(
464 crypto_option_spread_btc_deribit.quote_currency(),
465 Currency::USD()
466 );
467 assert_eq!(
468 crypto_option_spread_btc_deribit.settlement_currency(),
469 Currency::BTC()
470 );
471 assert!(!crypto_option_spread_btc_deribit.is_inverse());
472 assert_eq!(crypto_option_spread_btc_deribit.price_precision(), 4);
473 assert_eq!(crypto_option_spread_btc_deribit.size_precision(), 1);
474 assert_eq!(
475 crypto_option_spread_btc_deribit.size_increment(),
476 Quantity::from("0.1")
477 );
478 assert!(crypto_option_spread_btc_deribit.activation_ns().is_some());
479 assert!(crypto_option_spread_btc_deribit.expiration_ns().is_some());
480 }
481
482 #[rstest]
483 fn test_new_checked_price_precision_mismatch() {
484 let result = CryptoOptionSpread::new_checked(
485 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
486 Symbol::from("BTC-CS-TEST"),
487 Currency::BTC(),
488 Currency::USD(),
489 Currency::BTC(),
490 false,
491 ustr::Ustr::from("CS"),
492 0.into(),
493 0.into(),
494 4, 1,
496 Price::from("0.001"),
497 Quantity::from("0.1"),
498 None,
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 0.into(),
513 0.into(),
514 );
515 assert!(result.is_err());
516 }
517
518 #[rstest]
519 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
520 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
521 fn test_new_checked_rejects_non_positive_sizing(
522 #[case] multiplier: Option<Quantity>,
523 #[case] lot_size: Option<Quantity>,
524 ) {
525 let result = crypto_option_spread_result(multiplier, lot_size);
526 assert!(result.is_err());
527 }
528
529 #[rstest]
530 fn test_serialization_roundtrip(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
531 let json = serde_json::to_string(&crypto_option_spread_btc_deribit).unwrap();
532 let deserialized: CryptoOptionSpread = serde_json::from_str(&json).unwrap();
533 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
534 }
535
536 #[rstest]
537 fn test_builder_matches_new_checked() {
538 let positional = CryptoOptionSpread::new_checked(
539 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"),
540 Symbol::from("BTC-CS-19MAY26-70000_75000"),
541 Currency::BTC(),
542 Currency::USDC(),
543 Currency::USDT(),
544 false,
545 ustr::Ustr::from("CS"),
546 1.into(),
547 2.into(),
548 4,
549 1,
550 Price::from("0.0001"),
551 Quantity::from("0.1"),
552 Some(Quantity::from("10")),
553 Some(Quantity::from("5")),
554 Some(Quantity::from("1000.0")),
555 Some(Quantity::from("0.1")),
556 Some(Money::new(1_000_000.0, Currency::USDC())),
557 Some(Money::new(10.0, Currency::USDC())),
558 Some(Price::from("9.9999")),
559 Some(Price::from("0.0001")),
560 Some(dec!(0.01)),
561 Some(dec!(0.02)),
562 Some(dec!(0.0002)),
563 Some(dec!(0.0004)),
564 None,
565 None,
566 1.into(),
567 2.into(),
568 )
569 .unwrap();
570
571 let built = CryptoOptionSpread::builder()
572 .instrument_id(InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"))
573 .raw_symbol(Symbol::from("BTC-CS-19MAY26-70000_75000"))
574 .underlying(Currency::BTC())
575 .quote_currency(Currency::USDC())
576 .settlement_currency(Currency::USDT())
577 .is_inverse(false)
578 .strategy_type(ustr::Ustr::from("CS"))
579 .activation_ns(1.into())
580 .expiration_ns(2.into())
581 .price_precision(4)
582 .size_precision(1)
583 .price_increment(Price::from("0.0001"))
584 .size_increment(Quantity::from("0.1"))
585 .multiplier(Quantity::from("10"))
586 .lot_size(Quantity::from("5"))
587 .max_quantity(Quantity::from("1000.0"))
588 .min_quantity(Quantity::from("0.1"))
589 .max_notional(Money::new(1_000_000.0, Currency::USDC()))
590 .min_notional(Money::new(10.0, Currency::USDC()))
591 .max_price(Price::from("9.9999"))
592 .min_price(Price::from("0.0001"))
593 .margin_init(dec!(0.01))
594 .margin_maint(dec!(0.02))
595 .maker_fee(dec!(0.0002))
596 .taker_fee(dec!(0.0004))
597 .ts_event(1.into())
598 .ts_init(2.into())
599 .build()
600 .unwrap();
601
602 assert_eq!(
603 serde_json::to_value(&positional).unwrap(),
604 serde_json::to_value(&built).unwrap(),
605 );
606 }
607
608 fn crypto_option_spread_result(
609 multiplier: Option<Quantity>,
610 lot_size: Option<Quantity>,
611 ) -> CorrectnessResult<CryptoOptionSpread> {
612 CryptoOptionSpread::new_checked(
613 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
614 Symbol::from("BTC-CS-TEST"),
615 Currency::BTC(),
616 Currency::USD(),
617 Currency::BTC(),
618 false,
619 ustr::Ustr::from("CS"),
620 0.into(),
621 0.into(),
622 4,
623 1,
624 Price::from("0.0001"),
625 Quantity::from("0.1"),
626 multiplier,
627 lot_size,
628 None,
629 None,
630 None,
631 None,
632 None,
633 None,
634 None,
635 None,
636 None,
637 None,
638 None,
639 None,
640 0.into(),
641 0.into(),
642 )
643 }
644}