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};
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 info: Option<Params>,
103 pub ts_event: UnixNanos,
105 pub ts_init: UnixNanos,
107}
108
109impl OptionContract {
110 #[expect(clippy::too_many_arguments)]
119 pub fn new_checked(
120 instrument_id: InstrumentId,
121 raw_symbol: Symbol,
122 asset_class: AssetClass,
123 exchange: Option<Ustr>,
124 underlying: Ustr,
125 option_kind: OptionKind,
126 strike_price: Price,
127 currency: Currency,
128 activation_ns: UnixNanos,
129 expiration_ns: UnixNanos,
130 price_precision: u8,
131 price_increment: Price,
132 multiplier: Quantity,
133 lot_size: Quantity,
134 max_quantity: Option<Quantity>,
135 min_quantity: Option<Quantity>,
136 max_price: Option<Price>,
137 min_price: Option<Price>,
138 margin_init: Option<Decimal>,
139 margin_maint: Option<Decimal>,
140 maker_fee: Option<Decimal>,
141 taker_fee: Option<Decimal>,
142 info: Option<Params>,
143 ts_event: UnixNanos,
144 ts_init: UnixNanos,
145 ) -> CorrectnessResult<Self> {
146 check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
147 check_valid_string_ascii(underlying.as_str(), stringify!(underlying))?;
148 check_equal_u8(
149 price_precision,
150 price_increment.precision,
151 stringify!(price_precision),
152 stringify!(price_increment.precision),
153 )?;
154 check_positive_price(price_increment, stringify!(price_increment))?;
155 check_positive_quantity(multiplier, stringify!(multiplier))?;
156 check_positive_quantity(lot_size, stringify!(lot_size))?;
157
158 Ok(Self {
159 id: instrument_id,
160 raw_symbol,
161 asset_class,
162 exchange,
163 underlying,
164 option_kind,
165 activation_ns,
166 expiration_ns,
167 strike_price,
168 currency,
169 price_precision,
170 price_increment,
171 size_precision: 0,
172 size_increment: Quantity::from(1),
173 multiplier,
174 lot_size,
175 margin_init: margin_init.unwrap_or_default(),
176 margin_maint: margin_maint.unwrap_or_default(),
177 maker_fee: maker_fee.unwrap_or_default(),
178 taker_fee: taker_fee.unwrap_or_default(),
179 info,
180 max_quantity,
181 min_quantity: Some(min_quantity.unwrap_or(1.into())),
182 max_price,
183 min_price,
184 ts_event,
185 ts_init,
186 })
187 }
188
189 #[expect(clippy::too_many_arguments)]
195 #[must_use]
196 pub fn new(
197 instrument_id: InstrumentId,
198 raw_symbol: Symbol,
199 asset_class: AssetClass,
200 exchange: Option<Ustr>,
201 underlying: Ustr,
202 option_kind: OptionKind,
203 strike_price: Price,
204 currency: Currency,
205 activation_ns: UnixNanos,
206 expiration_ns: UnixNanos,
207 price_precision: u8,
208 price_increment: Price,
209 multiplier: Quantity,
210 lot_size: Quantity,
211 max_quantity: Option<Quantity>,
212 min_quantity: Option<Quantity>,
213 max_price: Option<Price>,
214 min_price: Option<Price>,
215 margin_init: Option<Decimal>,
216 margin_maint: Option<Decimal>,
217 maker_fee: Option<Decimal>,
218 taker_fee: Option<Decimal>,
219 info: Option<Params>,
220 ts_event: UnixNanos,
221 ts_init: UnixNanos,
222 ) -> Self {
223 Self::new_checked(
224 instrument_id,
225 raw_symbol,
226 asset_class,
227 exchange,
228 underlying,
229 option_kind,
230 strike_price,
231 currency,
232 activation_ns,
233 expiration_ns,
234 price_precision,
235 price_increment,
236 multiplier,
237 lot_size,
238 max_quantity,
239 min_quantity,
240 max_price,
241 min_price,
242 margin_init,
243 margin_maint,
244 maker_fee,
245 taker_fee,
246 info,
247 ts_event,
248 ts_init,
249 )
250 .expect_display(FAILED)
251 }
252}
253
254impl PartialEq<Self> for OptionContract {
255 fn eq(&self, other: &Self) -> bool {
256 self.id == other.id
257 }
258}
259
260impl Eq for OptionContract {}
261
262impl Hash for OptionContract {
263 fn hash<H: Hasher>(&self, state: &mut H) {
264 self.id.hash(state);
265 }
266}
267
268impl Instrument for OptionContract {
269 fn into_any(self) -> InstrumentAny {
270 InstrumentAny::OptionContract(self)
271 }
272
273 fn id(&self) -> InstrumentId {
274 self.id
275 }
276
277 fn raw_symbol(&self) -> Symbol {
278 self.raw_symbol
279 }
280
281 fn asset_class(&self) -> AssetClass {
282 self.asset_class
283 }
284
285 fn instrument_class(&self) -> InstrumentClass {
286 InstrumentClass::Option
287 }
288 fn underlying(&self) -> Option<Ustr> {
289 Some(self.underlying)
290 }
291
292 fn base_currency(&self) -> Option<Currency> {
293 None
294 }
295
296 fn quote_currency(&self) -> Currency {
297 self.currency
298 }
299
300 fn settlement_currency(&self) -> Currency {
301 self.currency
302 }
303
304 fn isin(&self) -> Option<Ustr> {
305 None
306 }
307
308 fn option_kind(&self) -> Option<OptionKind> {
309 Some(self.option_kind)
310 }
311
312 fn exchange(&self) -> Option<Ustr> {
313 self.exchange
314 }
315
316 fn strike_price(&self) -> Option<Price> {
317 Some(self.strike_price)
318 }
319
320 fn activation_ns(&self) -> Option<UnixNanos> {
321 Some(self.activation_ns)
322 }
323
324 fn expiration_ns(&self) -> Option<UnixNanos> {
325 Some(self.expiration_ns)
326 }
327
328 fn is_inverse(&self) -> bool {
329 false
330 }
331
332 fn price_precision(&self) -> u8 {
333 self.price_precision
334 }
335
336 fn size_precision(&self) -> u8 {
337 0
338 }
339
340 fn price_increment(&self) -> Price {
341 self.price_increment
342 }
343
344 fn size_increment(&self) -> Quantity {
345 Quantity::from(1)
346 }
347
348 fn multiplier(&self) -> Quantity {
349 self.multiplier
350 }
351
352 fn lot_size(&self) -> Option<Quantity> {
353 Some(self.lot_size)
354 }
355
356 fn max_quantity(&self) -> Option<Quantity> {
357 self.max_quantity
358 }
359
360 fn min_quantity(&self) -> Option<Quantity> {
361 self.min_quantity
362 }
363
364 fn max_notional(&self) -> Option<Money> {
365 None
366 }
367
368 fn min_notional(&self) -> Option<Money> {
369 None
370 }
371
372 fn max_price(&self) -> Option<Price> {
373 self.max_price
374 }
375
376 fn min_price(&self) -> Option<Price> {
377 self.min_price
378 }
379
380 fn ts_event(&self) -> UnixNanos {
381 self.ts_event
382 }
383
384 fn ts_init(&self) -> UnixNanos {
385 self.ts_init
386 }
387
388 fn margin_init(&self) -> Decimal {
389 self.margin_init
390 }
391
392 fn margin_maint(&self) -> Decimal {
393 self.margin_maint
394 }
395
396 fn maker_fee(&self) -> Decimal {
397 self.maker_fee
398 }
399
400 fn taker_fee(&self) -> Decimal {
401 self.taker_fee
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use rstest::rstest;
408 use ustr::Ustr;
409
410 use crate::{
411 enums::{AssetClass, InstrumentClass, OptionKind},
412 identifiers::{InstrumentId, Symbol},
413 instruments::{Instrument, OptionContract, stubs::*},
414 types::{Currency, Price, Quantity},
415 };
416
417 #[rstest]
418 fn test_trait_accessors(option_contract_appl: OptionContract) {
419 assert_eq!(
420 option_contract_appl.id(),
421 InstrumentId::from("AAPL211217C00150000.OPRA"),
422 );
423 assert_eq!(option_contract_appl.asset_class(), AssetClass::Equity);
424 assert_eq!(
425 option_contract_appl.instrument_class(),
426 InstrumentClass::Option
427 );
428 assert_eq!(option_contract_appl.quote_currency(), Currency::USD());
429 assert!(!option_contract_appl.is_inverse());
430 assert_eq!(option_contract_appl.option_kind(), Some(OptionKind::Call));
431 assert_eq!(
432 option_contract_appl.strike_price(),
433 Some(Price::from("149.0"))
434 );
435 assert_eq!(option_contract_appl.underlying(), Some(Ustr::from("AAPL")));
436 assert_eq!(option_contract_appl.exchange(), Some(Ustr::from("GMNI")));
437 assert!(option_contract_appl.activation_ns().is_some());
438 assert!(option_contract_appl.expiration_ns().is_some());
439 assert_eq!(option_contract_appl.size_precision(), 0);
440 assert_eq!(option_contract_appl.size_increment(), Quantity::from("1"));
441 assert_eq!(
442 option_contract_appl.min_quantity(),
443 Some(Quantity::from("1"))
444 );
445 }
446
447 #[rstest]
448 fn test_new_checked_price_precision_mismatch() {
449 let result = OptionContract::new_checked(
450 InstrumentId::from("TEST.OPRA"),
451 Symbol::from("TEST"),
452 AssetClass::Equity,
453 Some(Ustr::from("GMNI")),
454 Ustr::from("AAPL"),
455 OptionKind::Call,
456 Price::from("150.0"),
457 Currency::USD(),
458 0.into(),
459 0.into(),
460 4, Price::from("0.01"),
462 Quantity::from(1),
463 Quantity::from(1),
464 None,
465 None,
466 None,
467 None,
468 None,
469 None,
470 None,
471 None,
472 None,
473 0.into(),
474 0.into(),
475 );
476 assert!(result.is_err());
477 }
478
479 #[rstest]
480 fn test_new_checked_zero_multiplier() {
481 let result = OptionContract::new_checked(
482 InstrumentId::from("TEST.OPRA"),
483 Symbol::from("TEST"),
484 AssetClass::Equity,
485 Some(Ustr::from("GMNI")),
486 Ustr::from("AAPL"),
487 OptionKind::Call,
488 Price::from("150.0"),
489 Currency::USD(),
490 0.into(),
491 0.into(),
492 2,
493 Price::from("0.01"),
494 Quantity::from("0"), Quantity::from(1),
496 None,
497 None,
498 None,
499 None,
500 None,
501 None,
502 None,
503 None,
504 None,
505 0.into(),
506 0.into(),
507 );
508 assert!(result.is_err());
509 }
510
511 #[rstest]
512 fn test_serialization_roundtrip(option_contract_appl: OptionContract) {
513 let json = serde_json::to_string(&option_contract_appl).unwrap();
514 let deserialized: OptionContract = serde_json::from_str(&json).unwrap();
515 assert_eq!(option_contract_appl, deserialized);
516 }
517}