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