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