use crate::{
exchange::ExchangeId,
instrument::{
kind::option::{OptionExercise, OptionKind},
market_data::kind::MarketDataOptionContract,
},
};
use chrono::{NaiveDate, NaiveTime};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
#[non_exhaustive]
#[derive(Debug, Clone, Error)]
pub enum OptionChainError {
#[error("exercise style required: descriptor has no exercise field and none provided")]
ExerciseRequired,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct OptionChainDescriptor {
pub exchange: ExchangeId,
pub multiplier: Decimal,
pub expirations: Vec<NaiveDate>,
pub strikes: Vec<Decimal>,
pub exercise: Option<OptionExercise>,
}
impl OptionChainDescriptor {
pub fn new(
exchange: ExchangeId,
multiplier: Decimal,
expirations: Vec<NaiveDate>,
strikes: Vec<Decimal>,
exercise: Option<OptionExercise>,
) -> Self {
Self {
exchange,
multiplier,
expirations,
strikes,
exercise,
}
}
pub fn to_contracts(
&self,
exercise: Option<OptionExercise>,
) -> Result<Vec<MarketDataOptionContract>, OptionChainError> {
let exercise = exercise
.or(self.exercise)
.ok_or(OptionChainError::ExerciseRequired)?;
let mut contracts = Vec::with_capacity(self.contract_count());
for expiration in &self.expirations {
let expiry = expiration.and_time(NaiveTime::MIN).and_utc();
for &strike in &self.strikes {
contracts.push(MarketDataOptionContract {
kind: OptionKind::Call,
exercise,
expiry,
strike,
});
contracts.push(MarketDataOptionContract {
kind: OptionKind::Put,
exercise,
expiry,
strike,
});
}
}
Ok(contracts)
}
pub fn contract_count(&self) -> usize {
self.expirations
.len()
.saturating_mul(self.strikes.len())
.saturating_mul(2)
}
}
impl fmt::Display for OptionChainDescriptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"OptionChain({}: {} expirations × {} strikes, multiplier={})",
self.exchange,
self.expirations.len(),
self.strikes.len(),
self.multiplier
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use chrono::Timelike;
use rust_decimal_macros::dec;
fn sample_descriptor() -> OptionChainDescriptor {
OptionChainDescriptor::new(
ExchangeId::Ibkr,
dec!(100),
vec![
NaiveDate::from_ymd_opt(2024, 1, 19).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 16).unwrap(),
],
vec![dec!(145), dec!(150), dec!(155)],
None,
)
}
#[test]
fn to_contracts_with_exercise_param() {
let descriptor = sample_descriptor();
let contracts = descriptor
.to_contracts(Some(OptionExercise::American))
.unwrap();
assert_eq!(contracts.len(), 12);
assert_eq!(descriptor.contract_count(), 12);
assert_eq!(contracts[0].kind, OptionKind::Call);
assert_eq!(contracts[0].strike, dec!(145));
assert_eq!(contracts[0].exercise, OptionExercise::American);
assert_eq!(contracts[1].kind, OptionKind::Put);
assert_eq!(contracts[1].strike, dec!(145));
}
#[test]
fn to_contracts_uses_stored_exercise() {
let mut descriptor = sample_descriptor();
descriptor.exercise = Some(OptionExercise::European);
let contracts = descriptor.to_contracts(None).unwrap();
assert_eq!(contracts[0].exercise, OptionExercise::European);
}
#[test]
fn to_contracts_param_overrides_stored() {
let mut descriptor = sample_descriptor();
descriptor.exercise = Some(OptionExercise::European);
let contracts = descriptor
.to_contracts(Some(OptionExercise::American))
.unwrap();
assert_eq!(contracts[0].exercise, OptionExercise::American);
}
#[test]
fn to_contracts_error_when_no_exercise() {
let descriptor = sample_descriptor();
let result = descriptor.to_contracts(None);
assert!(matches!(result, Err(OptionChainError::ExerciseRequired)));
}
#[test]
fn to_contracts_expiry_is_midnight_utc() {
let descriptor = sample_descriptor();
let contracts = descriptor
.to_contracts(Some(OptionExercise::American))
.unwrap();
let expiry = contracts[0].expiry;
assert_eq!(expiry.hour(), 0);
assert_eq!(expiry.minute(), 0);
assert_eq!(expiry.second(), 0);
assert_eq!(
expiry.date_naive(),
NaiveDate::from_ymd_opt(2024, 1, 19).unwrap()
);
}
#[test]
fn empty_chain_produces_no_contracts() {
let descriptor = OptionChainDescriptor::new(
ExchangeId::AlpacaBroker,
dec!(100),
vec![],
vec![dec!(150)],
Some(OptionExercise::American),
);
let contracts = descriptor.to_contracts(None).unwrap();
assert!(contracts.is_empty());
assert_eq!(descriptor.contract_count(), 0);
}
#[test]
fn display_format() {
let descriptor = sample_descriptor();
let display = format!("{}", descriptor);
assert!(display.contains("Ibkr"));
assert!(display.contains("2 expirations"));
assert!(display.contains("3 strikes"));
assert!(display.contains("100"));
}
#[test]
fn serialization_roundtrip() {
let mut descriptor = sample_descriptor();
descriptor.exercise = Some(OptionExercise::American);
let json = serde_json::to_string(&descriptor).unwrap();
let parsed: OptionChainDescriptor = serde_json::from_str(&json).unwrap();
assert_eq!(descriptor, parsed);
}
}