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.map(|u| u.as_str()), stringify!(exchange))?;
143 check_valid_string_ascii(underlying.as_str(), 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 tick_scheme(&self) -> Option<Ustr> {
272 self.tick_scheme
273 }
274 fn into_any(self) -> InstrumentAny {
275 InstrumentAny::OptionContract(self)
276 }
277
278 fn id(&self) -> InstrumentId {
279 self.id
280 }
281
282 fn raw_symbol(&self) -> Symbol {
283 self.raw_symbol
284 }
285
286 fn asset_class(&self) -> AssetClass {
287 self.asset_class
288 }
289
290 fn instrument_class(&self) -> InstrumentClass {
291 InstrumentClass::Option
292 }
293 fn underlying(&self) -> Option<Ustr> {
294 Some(self.underlying)
295 }
296
297 fn base_currency(&self) -> Option<Currency> {
298 None
299 }
300
301 fn quote_currency(&self) -> Currency {
302 self.currency
303 }
304
305 fn settlement_currency(&self) -> Currency {
306 self.currency
307 }
308
309 fn isin(&self) -> Option<Ustr> {
310 None
311 }
312
313 fn option_kind(&self) -> Option<OptionKind> {
314 Some(self.option_kind)
315 }
316
317 fn exchange(&self) -> Option<Ustr> {
318 self.exchange
319 }
320
321 fn strike_price(&self) -> Option<Price> {
322 Some(self.strike_price)
323 }
324
325 fn activation_ns(&self) -> Option<UnixNanos> {
326 Some(self.activation_ns)
327 }
328
329 fn expiration_ns(&self) -> Option<UnixNanos> {
330 Some(self.expiration_ns)
331 }
332
333 fn is_inverse(&self) -> bool {
334 false
335 }
336
337 fn price_precision(&self) -> u8 {
338 self.price_precision
339 }
340
341 fn size_precision(&self) -> u8 {
342 0
343 }
344
345 fn price_increment(&self) -> Price {
346 self.price_increment
347 }
348
349 fn size_increment(&self) -> Quantity {
350 Quantity::from(1)
351 }
352
353 fn multiplier(&self) -> Quantity {
354 self.multiplier
355 }
356
357 fn lot_size(&self) -> Option<Quantity> {
358 Some(self.lot_size)
359 }
360
361 fn max_quantity(&self) -> Option<Quantity> {
362 self.max_quantity
363 }
364
365 fn min_quantity(&self) -> Option<Quantity> {
366 self.min_quantity
367 }
368
369 fn max_notional(&self) -> Option<Money> {
370 None
371 }
372
373 fn min_notional(&self) -> Option<Money> {
374 None
375 }
376
377 fn max_price(&self) -> Option<Price> {
378 self.max_price
379 }
380
381 fn min_price(&self) -> Option<Price> {
382 self.min_price
383 }
384
385 fn ts_event(&self) -> UnixNanos {
386 self.ts_event
387 }
388
389 fn ts_init(&self) -> UnixNanos {
390 self.ts_init
391 }
392
393 fn margin_init(&self) -> Decimal {
394 self.margin_init
395 }
396
397 fn margin_maint(&self) -> Decimal {
398 self.margin_maint
399 }
400
401 fn maker_fee(&self) -> Decimal {
402 self.maker_fee
403 }
404
405 fn taker_fee(&self) -> Decimal {
406 self.taker_fee
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use rstest::rstest;
413 use rust_decimal_macros::dec;
414 use ustr::Ustr;
415
416 use crate::{
417 enums::{AssetClass, InstrumentClass, OptionKind},
418 identifiers::{InstrumentId, Symbol},
419 instruments::{Instrument, OptionContract, stubs::*},
420 types::{Currency, Price, Quantity},
421 };
422
423 #[rstest]
424 fn test_trait_accessors(option_contract_appl: OptionContract) {
425 assert_eq!(
426 option_contract_appl.id(),
427 InstrumentId::from("AAPL211217C00150000.OPRA"),
428 );
429 assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
430 assert_eq!(
431 option_contract_appl.instrument_class(),
432 InstrumentClass::Option
433 );
434 assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
435 assert!(!option_contract_appl.is_inverse());
436 assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
437 assert_eq!(
438 option_contract_appl.strike_price(),
439 Some(Price::from("149.0"))
440 );
441 assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
442 assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
443 assert!(option_contract_appl.activation_ns().is_some());
444 assert!(option_contract_appl.expiration_ns().is_some());
445 assert_eq!(option_contract_appl.size_precision(), 0);
446 assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
447 assert_eq!(
448 option_contract_appl.min_quantity(),
449 Some(Quantity::from("1"))
450 );
451 }
452
453 #[rstest]
454 fn test_new_checked_price_precision_mismatch() {
455 let result = OptionContract::new_checked(
456 InstrumentId::from("TEST.OPRA"),
457 Symbol::from("TEST"),
458 AssetClass::Equity,
459 Some(Ustr::from("GMNI")),
460 Ustr::from("AAPL"),
461 OptionKind::Call,
462 Price::from("150.0"),
463 Currency::USD(),
464 0.into(),
465 0.into(),
466 4, Price::from("0.01"),
468 Quantity::from(1),
469 Quantity::from(1),
470 None,
471 None,
472 None,
473 None,
474 None,
475 None,
476 None,
477 None,
478 None,
479 None,
480 0.into(),
481 0.into(),
482 );
483 assert!(result.is_err());
484 }
485
486 #[rstest]
487 fn test_new_checked_zero_multiplier() {
488 let result = OptionContract::new_checked(
489 InstrumentId::from("TEST.OPRA"),
490 Symbol::from("TEST"),
491 AssetClass::Equity,
492 Some(Ustr::from("GMNI")),
493 Ustr::from("AAPL"),
494 OptionKind::Call,
495 Price::from("150.0"),
496 Currency::USD(),
497 0.into(),
498 0.into(),
499 2,
500 Price::from("0.01"),
501 Quantity::from("0"), Quantity::from(1),
503 None,
504 None,
505 None,
506 None,
507 None,
508 None,
509 None,
510 None,
511 None,
512 None,
513 0.into(),
514 0.into(),
515 );
516 assert!(result.is_err());
517 }
518
519 #[rstest]
520 #[case(Price::from("0"))]
521 #[case(Price::from("-1"))]
522 fn test_new_checked_rejects_non_positive_strike_price(#[case] strike_price: Price) {
523 let result = OptionContract::new_checked(
524 InstrumentId::from("TEST.OPRA"),
525 Symbol::from("TEST"),
526 AssetClass::Equity,
527 Some(Ustr::from("GMNI")),
528 Ustr::from("AAPL"),
529 OptionKind::Call,
530 strike_price,
531 Currency::USD(),
532 0.into(),
533 0.into(),
534 2,
535 Price::from("0.01"),
536 Quantity::from(1),
537 Quantity::from(1),
538 None,
539 None,
540 None,
541 None,
542 None,
543 None,
544 None,
545 None,
546 None,
547 None,
548 0.into(),
549 0.into(),
550 );
551
552 assert!(
555 result
556 .unwrap_err()
557 .to_string()
558 .contains("'strike_price' not positive")
559 );
560 }
561
562 #[rstest]
563 fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
564 let json = serde_json::to_string(&option_contract_appl).unwrap();
565 let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
566 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
567 }
568
569 #[rstest]
570 fn test_builder_matches_new_checked() {
571 let positional = OptionContract::new_checked(
572 InstrumentId::from("AAPL211217C00150000.OPRA"),
573 Symbol::from("AAPL211217C00150000"),
574 AssetClass::Equity,
575 Some(Ustr::from("GMNI")),
576 Ustr::from("AAPL"),
577 OptionKind::Call,
578 Price::from("149.0"),
579 Currency::USD(),
580 1.into(),
581 2.into(),
582 2,
583 Price::from("0.01"),
584 Quantity::from(10),
585 Quantity::from(5),
586 Some(Quantity::from("100")),
587 Some(Quantity::from("1")),
588 Some(Price::from("999.0")),
589 Some(Price::from("1.0")),
590 Some(dec!(0.01)),
591 Some(dec!(0.02)),
592 Some(dec!(0.0002)),
593 Some(dec!(0.0004)),
594 None,
595 None,
596 3.into(),
597 4.into(),
598 )
599 .unwrap();
600
601 let built = OptionContract::builder()
602 .instrument_id(InstrumentId::from("AAPL211217C00150000.OPRA"))
603 .raw_symbol(Symbol::from("AAPL211217C00150000"))
604 .asset_class(AssetClass::Equity)
605 .exchange(Ustr::from("GMNI"))
606 .underlying(Ustr::from("AAPL"))
607 .option_kind(OptionKind::Call)
608 .strike_price(Price::from("149.0"))
609 .currency(Currency::USD())
610 .activation_ns(1.into())
611 .expiration_ns(2.into())
612 .price_precision(2)
613 .price_increment(Price::from("0.01"))
614 .multiplier(Quantity::from(10))
615 .lot_size(Quantity::from(5))
616 .max_quantity(Quantity::from("100"))
617 .min_quantity(Quantity::from("1"))
618 .max_price(Price::from("999.0"))
619 .min_price(Price::from("1.0"))
620 .margin_init(dec!(0.01))
621 .margin_maint(dec!(0.02))
622 .maker_fee(dec!(0.0002))
623 .taker_fee(dec!(0.0004))
624 .ts_event(3.into())
625 .ts_init(4.into())
626 .build()
627 .unwrap();
628
629 assert_eq!(
630 serde_json::to_value(&positional).unwrap(),
631 serde_json::to_value(&built).unwrap(),
632 );
633 }
634}