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.core.nautilus_pyo3.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_tick_scheme(tick_scheme)?;
161 check_positive_quantity(multiplier, stringify!(multiplier))?;
162 check_positive_quantity(lot_size, stringify!(lot_size))?;
163
164 Ok(Self {
165 id: instrument_id,
166 raw_symbol,
167 asset_class,
168 exchange,
169 underlying,
170 option_kind,
171 activation_ns,
172 expiration_ns,
173 strike_price,
174 currency,
175 price_precision,
176 price_increment,
177 size_precision: 0,
178 size_increment: Quantity::from(1),
179 multiplier,
180 lot_size,
181 margin_init: margin_init.unwrap_or_default(),
182 margin_maint: margin_maint.unwrap_or_default(),
183 maker_fee: maker_fee.unwrap_or_default(),
184 taker_fee: taker_fee.unwrap_or_default(),
185 tick_scheme,
186 info,
187 max_quantity,
188 min_quantity: Some(min_quantity.unwrap_or(1.into())),
189 max_price,
190 min_price,
191 ts_event,
192 ts_init,
193 })
194 }
195
196 #[expect(clippy::too_many_arguments)]
202 #[must_use]
203 pub fn new(
204 instrument_id: InstrumentId,
205 raw_symbol: Symbol,
206 asset_class: AssetClass,
207 exchange: Option<Ustr>,
208 underlying: Ustr,
209 option_kind: OptionKind,
210 strike_price: Price,
211 currency: Currency,
212 activation_ns: UnixNanos,
213 expiration_ns: UnixNanos,
214 price_precision: u8,
215 price_increment: Price,
216 multiplier: Quantity,
217 lot_size: Quantity,
218 max_quantity: Option<Quantity>,
219 min_quantity: Option<Quantity>,
220 max_price: Option<Price>,
221 min_price: Option<Price>,
222 margin_init: Option<Decimal>,
223 margin_maint: Option<Decimal>,
224 maker_fee: Option<Decimal>,
225 taker_fee: Option<Decimal>,
226 tick_scheme: Option<Ustr>,
227 info: Option<Params>,
228 ts_event: UnixNanos,
229 ts_init: UnixNanos,
230 ) -> Self {
231 Self::new_checked(
232 instrument_id,
233 raw_symbol,
234 asset_class,
235 exchange,
236 underlying,
237 option_kind,
238 strike_price,
239 currency,
240 activation_ns,
241 expiration_ns,
242 price_precision,
243 price_increment,
244 multiplier,
245 lot_size,
246 max_quantity,
247 min_quantity,
248 max_price,
249 min_price,
250 margin_init,
251 margin_maint,
252 maker_fee,
253 taker_fee,
254 tick_scheme,
255 info,
256 ts_event,
257 ts_init,
258 )
259 .expect_display(FAILED)
260 }
261
262 #[builder(start_fn = builder, finish_fn = build)]
272 pub fn build_checked(
273 instrument_id: InstrumentId,
274 raw_symbol: Symbol,
275 asset_class: AssetClass,
276 exchange: Option<Ustr>,
277 underlying: Ustr,
278 option_kind: OptionKind,
279 strike_price: Price,
280 currency: Currency,
281 activation_ns: UnixNanos,
282 expiration_ns: UnixNanos,
283 price_precision: u8,
284 price_increment: Price,
285 multiplier: Quantity,
286 lot_size: Quantity,
287 max_quantity: Option<Quantity>,
288 min_quantity: Option<Quantity>,
289 max_price: Option<Price>,
290 min_price: Option<Price>,
291 margin_init: Option<Decimal>,
292 margin_maint: Option<Decimal>,
293 maker_fee: Option<Decimal>,
294 taker_fee: Option<Decimal>,
295 tick_scheme: Option<Ustr>,
296 info: Option<Params>,
297 ts_event: UnixNanos,
298 ts_init: UnixNanos,
299 ) -> CorrectnessResult<Self> {
300 Self::new_checked(
301 instrument_id,
302 raw_symbol,
303 asset_class,
304 exchange,
305 underlying,
306 option_kind,
307 strike_price,
308 currency,
309 activation_ns,
310 expiration_ns,
311 price_precision,
312 price_increment,
313 multiplier,
314 lot_size,
315 max_quantity,
316 min_quantity,
317 max_price,
318 min_price,
319 margin_init,
320 margin_maint,
321 maker_fee,
322 taker_fee,
323 tick_scheme,
324 info,
325 ts_event,
326 ts_init,
327 )
328 }
329}
330
331impl PartialEq<Self> for OptionContract {
332 fn eq(&self, other: &Self) -> bool {
333 self.id == other.id
334 }
335}
336
337impl Eq for OptionContract {}
338
339impl Hash for OptionContract {
340 fn hash<H: Hasher>(&self, state: &mut H) {
341 self.id.hash(state);
342 }
343}
344
345impl Instrument for OptionContract {
346 fn tick_scheme(&self) -> Option<Ustr> {
347 self.tick_scheme
348 }
349 fn into_any(self) -> InstrumentAny {
350 InstrumentAny::OptionContract(self)
351 }
352
353 fn id(&self) -> InstrumentId {
354 self.id
355 }
356
357 fn raw_symbol(&self) -> Symbol {
358 self.raw_symbol
359 }
360
361 fn asset_class(&self) -> AssetClass {
362 self.asset_class
363 }
364
365 fn instrument_class(&self) -> InstrumentClass {
366 InstrumentClass::Option
367 }
368 fn underlying(&self) -> Option<Ustr> {
369 Some(self.underlying)
370 }
371
372 fn base_currency(&self) -> Option<Currency> {
373 None
374 }
375
376 fn quote_currency(&self) -> Currency {
377 self.currency
378 }
379
380 fn settlement_currency(&self) -> Currency {
381 self.currency
382 }
383
384 fn isin(&self) -> Option<Ustr> {
385 None
386 }
387
388 fn option_kind(&self) -> Option<OptionKind> {
389 Some(self.option_kind)
390 }
391
392 fn exchange(&self) -> Option<Ustr> {
393 self.exchange
394 }
395
396 fn strike_price(&self) -> Option<Price> {
397 Some(self.strike_price)
398 }
399
400 fn activation_ns(&self) -> Option<UnixNanos> {
401 Some(self.activation_ns)
402 }
403
404 fn expiration_ns(&self) -> Option<UnixNanos> {
405 Some(self.expiration_ns)
406 }
407
408 fn is_inverse(&self) -> bool {
409 false
410 }
411
412 fn price_precision(&self) -> u8 {
413 self.price_precision
414 }
415
416 fn size_precision(&self) -> u8 {
417 0
418 }
419
420 fn price_increment(&self) -> Price {
421 self.price_increment
422 }
423
424 fn size_increment(&self) -> Quantity {
425 Quantity::from(1)
426 }
427
428 fn multiplier(&self) -> Quantity {
429 self.multiplier
430 }
431
432 fn lot_size(&self) -> Option<Quantity> {
433 Some(self.lot_size)
434 }
435
436 fn max_quantity(&self) -> Option<Quantity> {
437 self.max_quantity
438 }
439
440 fn min_quantity(&self) -> Option<Quantity> {
441 self.min_quantity
442 }
443
444 fn max_notional(&self) -> Option<Money> {
445 None
446 }
447
448 fn min_notional(&self) -> Option<Money> {
449 None
450 }
451
452 fn max_price(&self) -> Option<Price> {
453 self.max_price
454 }
455
456 fn min_price(&self) -> Option<Price> {
457 self.min_price
458 }
459
460 fn ts_event(&self) -> UnixNanos {
461 self.ts_event
462 }
463
464 fn ts_init(&self) -> UnixNanos {
465 self.ts_init
466 }
467
468 fn margin_init(&self) -> Decimal {
469 self.margin_init
470 }
471
472 fn margin_maint(&self) -> Decimal {
473 self.margin_maint
474 }
475
476 fn maker_fee(&self) -> Decimal {
477 self.maker_fee
478 }
479
480 fn taker_fee(&self) -> Decimal {
481 self.taker_fee
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use rstest::rstest;
488 use rust_decimal_macros::dec;
489 use ustr::Ustr;
490
491 use crate::{
492 enums::{AssetClass, InstrumentClass, OptionKind},
493 identifiers::{InstrumentId, Symbol},
494 instruments::{Instrument, OptionContract, stubs::*},
495 types::{Currency, Price, Quantity},
496 };
497
498 #[rstest]
499 fn test_trait_accessors(option_contract_appl: OptionContract) {
500 assert_eq!(
501 option_contract_appl.id(),
502 InstrumentId::from("AAPL211217C00150000.OPRA"),
503 );
504 assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
505 assert_eq!(
506 option_contract_appl.instrument_class(),
507 InstrumentClass::Option
508 );
509 assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
510 assert!(!option_contract_appl.is_inverse());
511 assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
512 assert_eq!(
513 option_contract_appl.strike_price(),
514 Some(Price::from("149.0"))
515 );
516 assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
517 assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
518 assert!(option_contract_appl.activation_ns().is_some());
519 assert!(option_contract_appl.expiration_ns().is_some());
520 assert_eq!(option_contract_appl.size_precision(), 0);
521 assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
522 assert_eq!(
523 option_contract_appl.min_quantity(),
524 Some(Quantity::from("1"))
525 );
526 }
527
528 #[rstest]
529 fn test_new_checked_price_precision_mismatch() {
530 let result = OptionContract::new_checked(
531 InstrumentId::from("TEST.OPRA"),
532 Symbol::from("TEST"),
533 AssetClass::Equity,
534 Some(Ustr::from("GMNI")),
535 Ustr::from("AAPL"),
536 OptionKind::Call,
537 Price::from("150.0"),
538 Currency::USD(),
539 0.into(),
540 0.into(),
541 4, Price::from("0.01"),
543 Quantity::from(1),
544 Quantity::from(1),
545 None,
546 None,
547 None,
548 None,
549 None,
550 None,
551 None,
552 None,
553 None,
554 None,
555 0.into(),
556 0.into(),
557 );
558 assert!(result.is_err());
559 }
560
561 #[rstest]
562 fn test_new_checked_zero_multiplier() {
563 let result = OptionContract::new_checked(
564 InstrumentId::from("TEST.OPRA"),
565 Symbol::from("TEST"),
566 AssetClass::Equity,
567 Some(Ustr::from("GMNI")),
568 Ustr::from("AAPL"),
569 OptionKind::Call,
570 Price::from("150.0"),
571 Currency::USD(),
572 0.into(),
573 0.into(),
574 2,
575 Price::from("0.01"),
576 Quantity::from("0"), Quantity::from(1),
578 None,
579 None,
580 None,
581 None,
582 None,
583 None,
584 None,
585 None,
586 None,
587 None,
588 0.into(),
589 0.into(),
590 );
591 assert!(result.is_err());
592 }
593
594 #[rstest]
595 fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
596 let json = serde_json::to_string(&option_contract_appl).unwrap();
597 let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
598 assert_eq!(option_contract_appl, deserialized);
599 }
600
601 #[rstest]
602 fn test_builder_matches_new_checked() {
603 let positional = OptionContract::new_checked(
604 InstrumentId::from("AAPL211217C00150000.OPRA"),
605 Symbol::from("AAPL211217C00150000"),
606 AssetClass::Equity,
607 Some(Ustr::from("GMNI")),
608 Ustr::from("AAPL"),
609 OptionKind::Call,
610 Price::from("149.0"),
611 Currency::USD(),
612 1.into(),
613 2.into(),
614 2,
615 Price::from("0.01"),
616 Quantity::from(10),
617 Quantity::from(5),
618 Some(Quantity::from("100")),
619 Some(Quantity::from("1")),
620 Some(Price::from("999.0")),
621 Some(Price::from("1.0")),
622 Some(dec!(0.01)),
623 Some(dec!(0.02)),
624 Some(dec!(0.0002)),
625 Some(dec!(0.0004)),
626 None,
627 None,
628 3.into(),
629 4.into(),
630 )
631 .unwrap();
632
633 let built = OptionContract::builder()
634 .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
635 .raw_symbol(Symbol::from("AAPL211217C00150000"))
636 .asset_class(AssetClass::Equity)
637 .exchange(Ustr::from("GMNI"))
638 .underlying(Ustr::from("AAPL"))
639 .option_kind(OptionKind::Call)
640 .strike_price(Price::from("149.0"))
641 .currency(Currency::USD())
642 .activation_ns(1.into())
643 .expiration_ns(2.into())
644 .price_precision(2)
645 .price_increment(Price::from("0.01"))
646 .multiplier(Quantity::from(10))
647 .lot_size(Quantity::from(5))
648 .max_quantity(Quantity::from("100"))
649 .min_quantity(Quantity::from("1"))
650 .max_price(Price::from("999.0"))
651 .min_price(Price::from("1.0"))
652 .margin_init(dec!(0.01))
653 .margin_maint(dec!(0.02))
654 .maker_fee(dec!(0.0002))
655 .taker_fee(dec!(0.0004))
656 .ts_event(3.into())
657 .ts_init(4.into())
658 .build()
659 .unwrap();
660
661 assert_eq!(
662 serde_json::to_value(&positional).unwrap(),
663 serde_json::to_value(&built).unwrap(),
664 );
665 }
666}