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 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 CryptoOptionSpread {
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_notional,
183 min_notional,
184 max_quantity,
185 min_quantity,
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 CryptoOptionSpread {
266 fn eq(&self, other: &Self) -> bool {
267 self.id == other.id
268 }
269}
270
271impl Eq for CryptoOptionSpread {}
272
273impl Hash for CryptoOptionSpread {
274 fn hash<H: Hasher>(&self, state: &mut H) {
275 self.id.hash(state);
276 }
277}
278
279impl Instrument for CryptoOptionSpread {
280 fn into_any(self) -> InstrumentAny {
281 InstrumentAny::CryptoOptionSpread(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::OptionSpread
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 is_inverse(&self) -> bool {
317 self.is_inverse
318 }
319
320 fn isin(&self) -> Option<Ustr> {
321 None
322 }
323
324 fn option_kind(&self) -> Option<OptionKind> {
325 None
326 }
327
328 fn strike_price(&self) -> Option<Price> {
329 None
330 }
331
332 fn strategy_type(&self) -> Option<Ustr> {
333 Some(self.strategy_type)
334 }
335
336 fn activation_ns(&self) -> Option<UnixNanos> {
337 Some(self.activation_ns)
338 }
339
340 fn expiration_ns(&self) -> Option<UnixNanos> {
341 Some(self.expiration_ns)
342 }
343
344 fn exchange(&self) -> Option<Ustr> {
345 None
346 }
347
348 fn price_precision(&self) -> u8 {
349 self.price_precision
350 }
351
352 fn size_precision(&self) -> u8 {
353 self.size_precision
354 }
355
356 fn price_increment(&self) -> Price {
357 self.price_increment
358 }
359
360 fn size_increment(&self) -> Quantity {
361 self.size_increment
362 }
363
364 fn multiplier(&self) -> Quantity {
365 self.multiplier
366 }
367
368 fn lot_size(&self) -> Option<Quantity> {
369 Some(self.lot_size)
370 }
371
372 fn max_quantity(&self) -> Option<Quantity> {
373 self.max_quantity
374 }
375
376 fn min_quantity(&self) -> Option<Quantity> {
377 self.min_quantity
378 }
379
380 fn max_notional(&self) -> Option<Money> {
381 self.max_notional
382 }
383
384 fn min_notional(&self) -> Option<Money> {
385 self.min_notional
386 }
387
388 fn max_price(&self) -> Option<Price> {
389 self.max_price
390 }
391
392 fn min_price(&self) -> Option<Price> {
393 self.min_price
394 }
395
396 fn tick_scheme(&self) -> Option<Ustr> {
397 self.tick_scheme
398 }
399
400 fn info(&self) -> Option<&Params> {
401 self.info.as_ref()
402 }
403
404 fn ts_event(&self) -> UnixNanos {
405 self.ts_event
406 }
407
408 fn ts_init(&self) -> UnixNanos {
409 self.ts_init
410 }
411
412 fn margin_init(&self) -> Decimal {
413 self.margin_init
414 }
415
416 fn margin_maint(&self) -> Decimal {
417 self.margin_maint
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
427 use crate::{
428 enums::{AssetClass, InstrumentClass},
429 identifiers::{InstrumentId, Symbol},
430 instruments::{CryptoOptionSpread, Instrument, stubs::*},
431 types::{Currency, Money, Price, Quantity},
432 };
433
434 #[rstest]
435 fn test_trait_accessors(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
436 assert_eq!(
437 crypto_option_spread_btc_deribit.id(),
438 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT")
439 );
440 assert_eq!(
441 crypto_option_spread_btc_deribit.asset_class(),
442 AssetClass::Cryptocurrency
443 );
444 assert_eq!(
445 crypto_option_spread_btc_deribit.instrument_class(),
446 InstrumentClass::OptionSpread
447 );
448 assert_eq!(
449 crypto_option_spread_btc_deribit.quote_currency(),
450 Currency::USD()
451 );
452 assert_eq!(
453 crypto_option_spread_btc_deribit.settlement_currency(),
454 Currency::BTC()
455 );
456 assert!(!crypto_option_spread_btc_deribit.is_inverse());
457 assert_eq!(crypto_option_spread_btc_deribit.price_precision(), 4);
458 assert_eq!(crypto_option_spread_btc_deribit.size_precision(), 1);
459 assert_eq!(
460 crypto_option_spread_btc_deribit.size_increment(),
461 Quantity::from("0.1")
462 );
463 assert!(crypto_option_spread_btc_deribit.activation_ns().is_some());
464 assert!(crypto_option_spread_btc_deribit.expiration_ns().is_some());
465 }
466
467 #[rstest]
468 fn test_new_checked_price_precision_mismatch() {
469 let result = CryptoOptionSpread::new_checked(
470 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
471 Symbol::from("BTC-CS-TEST"),
472 Currency::BTC(),
473 Currency::USD(),
474 Currency::BTC(),
475 false,
476 ustr::Ustr::from("CS"),
477 0.into(),
478 0.into(),
479 4, 1,
481 Price::from("0.001"),
482 Quantity::from("0.1"),
483 None,
484 None,
485 None,
486 None,
487 None,
488 None,
489 None,
490 None,
491 None,
492 None,
493 None,
494 None,
495 0.into(),
496 0.into(),
497 );
498 assert!(result.is_err());
499 }
500
501 #[rstest]
502 #[case::zero_multiplier(Some(Quantity::from("0")), None)]
503 #[case::zero_lot_size(None, Some(Quantity::from("0")))]
504 fn test_new_checked_rejects_non_positive_sizing(
505 #[case] multiplier: Option<Quantity>,
506 #[case] lot_size: Option<Quantity>,
507 ) {
508 let result = crypto_option_spread_result(multiplier, lot_size);
509 assert!(result.is_err());
510 }
511
512 #[rstest]
513 fn test_serialization_roundtrip(crypto_option_spread_btc_deribit: CryptoOptionSpread) {
514 let json = serde_json::to_string(&crypto_option_spread_btc_deribit).unwrap();
515 let deserialized: CryptoOptionSpread = serde_json::from_str(&json).unwrap();
516 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
517 }
518
519 #[rstest]
520 fn test_builder_matches_new_checked() {
521 let positional = CryptoOptionSpread::new_checked(
522 InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"),
523 Symbol::from("BTC-CS-19MAY26-70000_75000"),
524 Currency::BTC(),
525 Currency::USDC(),
526 Currency::USDT(),
527 false,
528 ustr::Ustr::from("CS"),
529 1.into(),
530 2.into(),
531 4,
532 1,
533 Price::from("0.0001"),
534 Quantity::from("0.1"),
535 Some(Quantity::from("10")),
536 Some(Quantity::from("5")),
537 Some(Quantity::from("1000.0")),
538 Some(Quantity::from("0.1")),
539 Some(Money::new(1_000_000.0, Currency::USDC())),
540 Some(Money::new(10.0, Currency::USDC())),
541 Some(Price::from("9.9999")),
542 Some(Price::from("0.0001")),
543 Some(dec!(0.01)),
544 Some(dec!(0.02)),
545 None,
546 None,
547 1.into(),
548 2.into(),
549 )
550 .unwrap();
551
552 let built = CryptoOptionSpread::builder()
553 .instrument_id(InstrumentId::from("BTC-CS-19MAY26-70000_75000.DERIBIT"))
554 .raw_symbol(Symbol::from("BTC-CS-19MAY26-70000_75000"))
555 .underlying(Currency::BTC())
556 .quote_currency(Currency::USDC())
557 .settlement_currency(Currency::USDT())
558 .is_inverse(false)
559 .strategy_type(ustr::Ustr::from("CS"))
560 .activation_ns(1.into())
561 .expiration_ns(2.into())
562 .price_precision(4)
563 .size_precision(1)
564 .price_increment(Price::from("0.0001"))
565 .size_increment(Quantity::from("0.1"))
566 .multiplier(Quantity::from("10"))
567 .lot_size(Quantity::from("5"))
568 .max_quantity(Quantity::from("1000.0"))
569 .min_quantity(Quantity::from("0.1"))
570 .max_notional(Money::new(1_000_000.0, Currency::USDC()))
571 .min_notional(Money::new(10.0, Currency::USDC()))
572 .max_price(Price::from("9.9999"))
573 .min_price(Price::from("0.0001"))
574 .margin_init(dec!(0.01))
575 .margin_maint(dec!(0.02))
576 .ts_event(1.into())
577 .ts_init(2.into())
578 .build()
579 .unwrap();
580
581 assert_eq!(
582 serde_json::to_value(&positional).unwrap(),
583 serde_json::to_value(&built).unwrap(),
584 );
585 }
586
587 fn crypto_option_spread_result(
588 multiplier: Option<Quantity>,
589 lot_size: Option<Quantity>,
590 ) -> CorrectnessResult<CryptoOptionSpread> {
591 CryptoOptionSpread::new_checked(
592 InstrumentId::from("BTC-CS-TEST.DERIBIT"),
593 Symbol::from("BTC-CS-TEST"),
594 Currency::BTC(),
595 Currency::USD(),
596 Currency::BTC(),
597 false,
598 ustr::Ustr::from("CS"),
599 0.into(),
600 0.into(),
601 4,
602 1,
603 Price::from("0.0001"),
604 Quantity::from("0.1"),
605 multiplier,
606 lot_size,
607 None,
608 None,
609 None,
610 None,
611 None,
612 None,
613 None,
614 None,
615 None,
616 None,
617 0.into(),
618 0.into(),
619 )
620 }
621}