1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8, check_valid_string_ascii,
22 check_valid_string_ascii_optional,
23 },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
30use crate::{
31 enums::{AssetClass, InstrumentClass, OptionKind},
32 identifiers::{InstrumentId, Symbol},
33 types::{
34 currency::Currency,
35 money::Money,
36 price::{Price, check_positive_price},
37 quantity::{Quantity, check_positive_quantity},
38 },
39};
40
41#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
47)]
48#[cfg_attr(
49 feature = "python",
50 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
51)]
52pub struct OptionContract {
53 pub id: InstrumentId,
55 pub raw_symbol: Symbol,
57 pub asset_class: AssetClass,
59 pub exchange: Option<Ustr>,
61 pub underlying: Ustr,
63 pub option_kind: OptionKind,
65 pub strike_price: Price,
67 pub activation_ns: UnixNanos,
69 pub expiration_ns: UnixNanos,
71 pub currency: Currency,
73 pub price_precision: u8,
75 pub price_increment: Price,
77 pub size_increment: Quantity,
79 pub size_precision: u8,
81 pub multiplier: Quantity,
83 pub lot_size: Quantity,
85 pub margin_init: Decimal,
87 pub margin_maint: Decimal,
89 pub maker_fee: Decimal,
91 pub taker_fee: Decimal,
93 pub max_quantity: Option<Quantity>,
95 pub min_quantity: Option<Quantity>,
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 OptionContract {
113 #[expect(clippy::too_many_arguments)]
123 pub fn new_checked(
124 instrument_id: InstrumentId,
125 raw_symbol: Symbol,
126 asset_class: AssetClass,
127 exchange: Option<Ustr>,
128 underlying: Ustr,
129 option_kind: OptionKind,
130 strike_price: Price,
131 currency: Currency,
132 activation_ns: UnixNanos,
133 expiration_ns: UnixNanos,
134 price_precision: u8,
135 price_increment: Price,
136 multiplier: Quantity,
137 lot_size: Quantity,
138 max_quantity: Option<Quantity>,
139 min_quantity: Option<Quantity>,
140 max_price: Option<Price>,
141 min_price: Option<Price>,
142 margin_init: Option<Decimal>,
143 margin_maint: Option<Decimal>,
144 maker_fee: Option<Decimal>,
145 taker_fee: Option<Decimal>,
146 tick_scheme: Option<Ustr>,
147 info: Option<Params>,
148 ts_event: UnixNanos,
149 ts_init: UnixNanos,
150 ) -> CorrectnessResult<Self> {
151 check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
152 check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
153 check_equal_u8(
154 price_precision,
155 price_increment.precision,
156 stringify!(price_precision),
157 stringify!(price_increment.precision),
158 )?;
159 check_positive_price(price_increment, stringify!(price_increment))?;
160 check_positive_price(strike_price, stringify!(strike_price))?;
161 check_tick_scheme(tick_scheme)?;
162 check_positive_quantity(multiplier, stringify!(multiplier))?;
163 check_positive_quantity(lot_size, stringify!(lot_size))?;
164
165 Ok(Self {
166 id: instrument_id,
167 raw_symbol,
168 asset_class,
169 exchange,
170 underlying,
171 option_kind,
172 activation_ns,
173 expiration_ns,
174 strike_price,
175 currency,
176 price_precision,
177 price_increment,
178 size_precision: 0,
179 size_increment: Quantity::from(1),
180 multiplier,
181 lot_size,
182 margin_init: margin_init.unwrap_or_default(),
183 margin_maint: margin_maint.unwrap_or_default(),
184 maker_fee: maker_fee.unwrap_or_default(),
185 taker_fee: taker_fee.unwrap_or_default(),
186 tick_scheme,
187 info,
188 max_quantity,
189 min_quantity: Some(min_quantity.unwrap_or(1.into())),
190 max_price,
191 min_price,
192 ts_event,
193 ts_init,
194 })
195 }
196
197 #[expect(clippy::too_many_arguments)]
203 #[must_use]
204 pub fn new(
205 instrument_id: InstrumentId,
206 raw_symbol: Symbol,
207 asset_class: AssetClass,
208 exchange: Option<Ustr>,
209 underlying: Ustr,
210 option_kind: OptionKind,
211 strike_price: Price,
212 currency: Currency,
213 activation_ns: UnixNanos,
214 expiration_ns: UnixNanos,
215 price_precision: u8,
216 price_increment: Price,
217 multiplier: Quantity,
218 lot_size: Quantity,
219 max_quantity: Option<Quantity>,
220 min_quantity: Option<Quantity>,
221 max_price: Option<Price>,
222 min_price: Option<Price>,
223 margin_init: Option<Decimal>,
224 margin_maint: Option<Decimal>,
225 maker_fee: Option<Decimal>,
226 taker_fee: Option<Decimal>,
227 tick_scheme: Option<Ustr>,
228 info: Option<Params>,
229 ts_event: UnixNanos,
230 ts_init: UnixNanos,
231 ) -> Self {
232 Self::new_checked(
233 instrument_id,
234 raw_symbol,
235 asset_class,
236 exchange,
237 underlying,
238 option_kind,
239 strike_price,
240 currency,
241 activation_ns,
242 expiration_ns,
243 price_precision,
244 price_increment,
245 multiplier,
246 lot_size,
247 max_quantity,
248 min_quantity,
249 max_price,
250 min_price,
251 margin_init,
252 margin_maint,
253 maker_fee,
254 taker_fee,
255 tick_scheme,
256 info,
257 ts_event,
258 ts_init,
259 )
260 .expect_display(FAILED)
261 }
262
263 #[builder(start_fn = builder, finish_fn = build)]
273 pub fn build_checked(
274 instrument_id: InstrumentId,
275 raw_symbol: Symbol,
276 asset_class: AssetClass,
277 exchange: Option<Ustr>,
278 underlying: Ustr,
279 option_kind: OptionKind,
280 strike_price: Price,
281 currency: Currency,
282 activation_ns: UnixNanos,
283 expiration_ns: UnixNanos,
284 price_precision: u8,
285 price_increment: Price,
286 multiplier: Quantity,
287 lot_size: Quantity,
288 max_quantity: Option<Quantity>,
289 min_quantity: Option<Quantity>,
290 max_price: Option<Price>,
291 min_price: Option<Price>,
292 margin_init: Option<Decimal>,
293 margin_maint: Option<Decimal>,
294 maker_fee: Option<Decimal>,
295 taker_fee: Option<Decimal>,
296 tick_scheme: Option<Ustr>,
297 info: Option<Params>,
298 ts_event: UnixNanos,
299 ts_init: UnixNanos,
300 ) -> CorrectnessResult<Self> {
301 Self::new_checked(
302 instrument_id,
303 raw_symbol,
304 asset_class,
305 exchange,
306 underlying,
307 option_kind,
308 strike_price,
309 currency,
310 activation_ns,
311 expiration_ns,
312 price_precision,
313 price_increment,
314 multiplier,
315 lot_size,
316 max_quantity,
317 min_quantity,
318 max_price,
319 min_price,
320 margin_init,
321 margin_maint,
322 maker_fee,
323 taker_fee,
324 tick_scheme,
325 info,
326 ts_event,
327 ts_init,
328 )
329 }
330}
331
332impl PartialEq<Self> for OptionContract {
333 fn eq(&self, other: &Self) -> bool {
334 self.id == other.id
335 }
336}
337
338impl Eq for OptionContract {}
339
340impl Hash for OptionContract {
341 fn hash<H: Hasher>(&self, state: &mut H) {
342 self.id.hash(state);
343 }
344}
345
346impl Instrument for OptionContract {
347 fn tick_scheme(&self) -> Option<Ustr> {
348 self.tick_scheme
349 }
350 fn into_any(self) -> InstrumentAny {
351 InstrumentAny::OptionContract(self)
352 }
353
354 fn id(&self) -> InstrumentId {
355 self.id
356 }
357
358 fn raw_symbol(&self) -> Symbol {
359 self.raw_symbol
360 }
361
362 fn asset_class(&self) -> AssetClass {
363 self.asset_class
364 }
365
366 fn instrument_class(&self) -> InstrumentClass {
367 InstrumentClass::Option
368 }
369 fn underlying(&self) -> Option<Ustr> {
370 Some(self.underlying)
371 }
372
373 fn base_currency(&self) -> Option<Currency> {
374 None
375 }
376
377 fn quote_currency(&self) -> Currency {
378 self.currency
379 }
380
381 fn settlement_currency(&self) -> Currency {
382 self.currency
383 }
384
385 fn isin(&self) -> Option<Ustr> {
386 None
387 }
388
389 fn option_kind(&self) -> Option<OptionKind> {
390 Some(self.option_kind)
391 }
392
393 fn exchange(&self) -> Option<Ustr> {
394 self.exchange
395 }
396
397 fn strike_price(&self) -> Option<Price> {
398 Some(self.strike_price)
399 }
400
401 fn activation_ns(&self) -> Option<UnixNanos> {
402 Some(self.activation_ns)
403 }
404
405 fn expiration_ns(&self) -> Option<UnixNanos> {
406 Some(self.expiration_ns)
407 }
408
409 fn is_inverse(&self) -> bool {
410 false
411 }
412
413 fn price_precision(&self) -> u8 {
414 self.price_precision
415 }
416
417 fn size_precision(&self) -> u8 {
418 0
419 }
420
421 fn price_increment(&self) -> Price {
422 self.price_increment
423 }
424
425 fn size_increment(&self) -> Quantity {
426 Quantity::from(1)
427 }
428
429 fn multiplier(&self) -> Quantity {
430 self.multiplier
431 }
432
433 fn lot_size(&self) -> Option<Quantity> {
434 Some(self.lot_size)
435 }
436
437 fn max_quantity(&self) -> Option<Quantity> {
438 self.max_quantity
439 }
440
441 fn min_quantity(&self) -> Option<Quantity> {
442 self.min_quantity
443 }
444
445 fn max_notional(&self) -> Option<Money> {
446 None
447 }
448
449 fn min_notional(&self) -> Option<Money> {
450 None
451 }
452
453 fn max_price(&self) -> Option<Price> {
454 self.max_price
455 }
456
457 fn min_price(&self) -> Option<Price> {
458 self.min_price
459 }
460
461 fn ts_event(&self) -> UnixNanos {
462 self.ts_event
463 }
464
465 fn ts_init(&self) -> UnixNanos {
466 self.ts_init
467 }
468
469 fn margin_init(&self) -> Decimal {
470 self.margin_init
471 }
472
473 fn margin_maint(&self) -> Decimal {
474 self.margin_maint
475 }
476
477 fn maker_fee(&self) -> Decimal {
478 self.maker_fee
479 }
480
481 fn taker_fee(&self) -> Decimal {
482 self.taker_fee
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use rstest::rstest;
489 use rust_decimal_macros::dec;
490 use ustr::Ustr;
491
492 use crate::{
493 enums::{AssetClass, InstrumentClass, OptionKind},
494 identifiers::{InstrumentId, Symbol},
495 instruments::{Instrument, OptionContract, stubs::*},
496 types::{Currency, Price, Quantity},
497 };
498
499 #[rstest]
500 fn test_trait_accessors(option_contract_appl: OptionContract) {
501 assert_eq!(
502 option_contract_appl.id(),
503 InstrumentId::from("AAPL211217C00150000.OPRA"),
504 );
505 assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
506 assert_eq!(
507 option_contract_appl.instrument_class(),
508 InstrumentClass::Option
509 );
510 assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
511 assert!(!option_contract_appl.is_inverse());
512 assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
513 assert_eq!(
514 option_contract_appl.strike_price(),
515 Some(Price::from("149.0"))
516 );
517 assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
518 assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
519 assert!(option_contract_appl.activation_ns().is_some());
520 assert!(option_contract_appl.expiration_ns().is_some());
521 assert_eq!(option_contract_appl.size_precision(), 0);
522 assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
523 assert_eq!(
524 option_contract_appl.min_quantity(),
525 Some(Quantity::from("1"))
526 );
527 }
528
529 #[rstest]
530 fn test_new_checked_price_precision_mismatch() {
531 let result = OptionContract::new_checked(
532 InstrumentId::from("TEST.OPRA"),
533 Symbol::from("TEST"),
534 AssetClass::Equity,
535 Some(Ustr::from("GMNI")),
536 Ustr::from("AAPL"),
537 OptionKind::Call,
538 Price::from("150.0"),
539 Currency::USD(),
540 0.into(),
541 0.into(),
542 4, Price::from("0.01"),
544 Quantity::from(1),
545 Quantity::from(1),
546 None,
547 None,
548 None,
549 None,
550 None,
551 None,
552 None,
553 None,
554 None,
555 None,
556 0.into(),
557 0.into(),
558 );
559 assert!(result.is_err());
560 }
561
562 #[rstest]
563 fn test_new_checked_zero_multiplier() {
564 let result = OptionContract::new_checked(
565 InstrumentId::from("TEST.OPRA"),
566 Symbol::from("TEST"),
567 AssetClass::Equity,
568 Some(Ustr::from("GMNI")),
569 Ustr::from("AAPL"),
570 OptionKind::Call,
571 Price::from("150.0"),
572 Currency::USD(),
573 0.into(),
574 0.into(),
575 2,
576 Price::from("0.01"),
577 Quantity::from("0"), Quantity::from(1),
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 None,
589 0.into(),
590 0.into(),
591 );
592 assert!(result.is_err());
593 }
594
595 #[rstest]
596 #[case(Price::from("0"))]
597 #[case(Price::from("-1"))]
598 fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
599 let result = OptionContract::new_checked(
600 InstrumentId::from("TEST.OPRA"),
601 Symbol::from("TEST"),
602 AssetClass::Equity,
603 Some(Ustr::from("GMNI")),
604 Ustr::from("AAPL"),
605 OptionKind::Call,
606 strike_price,
607 Currency::USD(),
608 0.into(),
609 0.into(),
610 2,
611 Price::from("0.01"),
612 Quantity::from(1),
613 Quantity::from(1),
614 None,
615 None,
616 None,
617 None,
618 None,
619 None,
620 None,
621 None,
622 None,
623 None,
624 0.into(),
625 0.into(),
626 );
627
628 assert!(
631 result
632 .unwrap_err()
633 .to_string()
634 .contains("'strike_price' not positive")
635 );
636 }
637
638 #[rstest]
639 fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
640 let json = serde_json::to_string(&option_contract_appl).unwrap();
641 let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
642 assert_eq!(option_contract_appl, deserialized);
643 }
644
645 #[rstest]
646 fn test_builder_matches_new_checked() {
647 let positional = OptionContract::new_checked(
648 InstrumentId::from("AAPL211217C00150000.OPRA"),
649 Symbol::from("AAPL211217C00150000"),
650 AssetClass::Equity,
651 Some(Ustr::from("GMNI")),
652 Ustr::from("AAPL"),
653 OptionKind::Call,
654 Price::from("149.0"),
655 Currency::USD(),
656 1.into(),
657 2.into(),
658 2,
659 Price::from("0.01"),
660 Quantity::from(10),
661 Quantity::from(5),
662 Some(Quantity::from("100")),
663 Some(Quantity::from("1")),
664 Some(Price::from("999.0")),
665 Some(Price::from("1.0")),
666 Some(dec!(0.01)),
667 Some(dec!(0.02)),
668 Some(dec!(0.0002)),
669 Some(dec!(0.0004)),
670 None,
671 None,
672 3.into(),
673 4.into(),
674 )
675 .unwrap();
676
677 let built = OptionContract::builder()
678 .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
679 .raw_symbol(Symbol::from("AAPL211217C00150000"))
680 .asset_class(AssetClass::Equity)
681 .exchange(Ustr::from("GMNI"))
682 .underlying(Ustr::from("AAPL"))
683 .option_kind(OptionKind::Call)
684 .strike_price(Price::from("149.0"))
685 .currency(Currency::USD())
686 .activation_ns(1.into())
687 .expiration_ns(2.into())
688 .price_precision(2)
689 .price_increment(Price::from("0.01"))
690 .multiplier(Quantity::from(10))
691 .lot_size(Quantity::from(5))
692 .max_quantity(Quantity::from("100"))
693 .min_quantity(Quantity::from("1"))
694 .max_price(Price::from("999.0"))
695 .min_price(Price::from("1.0"))
696 .margin_init(dec!(0.01))
697 .margin_maint(dec!(0.02))
698 .maker_fee(dec!(0.0002))
699 .taker_fee(dec!(0.0004))
700 .ts_event(3.into())
701 .ts_init(4.into())
702 .build()
703 .unwrap();
704
705 assert_eq!(
706 serde_json::to_value(&positional).unwrap(),
707 serde_json::to_value(&built).unwrap(),
708 );
709 }
710}