1use super::{DerivativeKind, Product, TradableProduct};
2use anyhow::{anyhow, bail, Result};
3use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
4use derive_more::{AsRef, Display};
5use rust_decimal::Decimal;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 fmt,
11 str::{self, FromStr},
12 sync::LazyLock,
13};
14use strum_macros::{EnumString, IntoStaticStr};
15
16#[derive(
18 AsRef,
19 Debug,
20 Display,
21 Clone,
22 Hash,
23 PartialEq,
24 Eq,
25 PartialOrd,
26 Ord,
27 Deserialize,
28 Serialize,
29 JsonSchema,
30)]
31#[serde(transparent)]
32#[cfg_attr(feature = "postgres", derive(postgres_types::ToSql))]
33#[cfg_attr(feature = "postgres", postgres(transparent))]
34pub struct OptionsSeries(String);
35
36impl OptionsSeries {
37 pub(crate) fn new_unchecked(name: impl AsRef<str>) -> Self {
38 Self(name.as_ref().to_string())
39 }
40}
41
42impl FromStr for OptionsSeries {
43 type Err = anyhow::Error;
44
45 fn from_str(s: &str) -> Result<Self> {
46 Ok(Self::new_unchecked(s))
48 }
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
52pub struct OptionsSeriesInfo {
53 pub options_series: OptionsSeries,
54 pub venue_discriminant: Option<String>,
55 pub quote_symbol: Product,
56 pub underlying: Product,
57 pub multiplier: Decimal,
58 pub expiration_time_of_day: NaiveTime,
59 #[schemars(with = "String")]
60 pub expiration_time_zone: chrono_tz::Tz,
61 pub strikes_by_expiration: BTreeMap<NaiveDate, BTreeSet<Decimal>>,
62 pub derivative_kind: DerivativeKind,
63 pub exercise_type: OptionsExerciseType,
64 pub is_cash_settled: bool,
65}
66
67impl OptionsSeriesInfo {
68 pub fn get_product(&self, instance: &OptionsSeriesInstance) -> Result<Product> {
69 let OptionsSeriesInstance { expiration, strike, put_or_call } = instance;
70 let stem_and_venue_discriminant = self
71 .options_series
72 .0
73 .strip_suffix(" Options")
74 .ok_or_else(|| anyhow!("invalid options series name"))?;
75 let stem = if let Some(venue_discriminant) = &self.venue_discriminant {
76 stem_and_venue_discriminant
77 .strip_suffix(venue_discriminant.as_str())
78 .ok_or_else(|| anyhow!("invalid options series name"))?
79 .trim_end()
80 } else {
81 stem_and_venue_discriminant
82 };
83 Product::option(
84 stem,
85 expiration.date_naive(),
86 *strike,
87 *put_or_call,
88 self.venue_discriminant.as_deref(),
89 )
90 }
91
92 pub fn get_tradable_product(
93 &self,
94 instance: &OptionsSeriesInstance,
95 ) -> Result<TradableProduct> {
96 let base = self.get_product(instance)?;
97 TradableProduct::new(&base, Some(&self.quote_symbol))
98 }
99
100 pub fn parse_instance(
101 &self,
102 symbol: impl AsRef<str>,
103 ) -> Result<OptionsSeriesInstance> {
104 static OPTION_SYMBOL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
105 regex::Regex::new(r"^([\w\s]+) (\d{8}) ([\d\.]+) ([PC])( \w*)? Option/?.*$")
106 .unwrap()
107 });
108
109 let symbol = symbol.as_ref();
111 let caps = OPTION_SYMBOL_RE
112 .captures(symbol)
113 .ok_or_else(|| anyhow!("symbol does not match expected format"))?;
114
115 let expiration_str = &caps[2];
116 let expiration_date = NaiveDate::parse_from_str(expiration_str, "%Y%m%d")?;
117 let expiration = expiration_date
119 .and_time(self.expiration_time_of_day)
120 .and_local_timezone(self.expiration_time_zone)
121 .single()
122 .ok_or_else(|| anyhow!("expiration time ambiguous with given time zone"))?
123 .to_utc();
124
125 let strike = caps[3].parse::<Decimal>()?;
126 let put_or_call = caps[4].parse::<PutOrCall>()?;
127
128 if let Some(expected_venue) = &self.venue_discriminant {
130 let venue_match =
131 caps.get(5).ok_or_else(|| anyhow!("missing venue discriminant"))?;
132 let venue = venue_match.as_str().trim();
133 if venue.is_empty() || venue != expected_venue {
134 bail!("venue discriminant mismatch");
135 }
136 }
137
138 Ok(OptionsSeriesInstance { expiration, strike, put_or_call })
139 }
140}
141
142#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
144pub struct OptionsSeriesInstance {
145 pub expiration: DateTime<Utc>,
146 pub strike: Decimal,
147 pub put_or_call: PutOrCall,
148}
149
150#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
151pub struct OptionsStrikes {
152 pub start: Decimal,
153 pub end: Decimal,
154 pub stride: Decimal,
155}
156
157#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
158pub struct OptionsExpirations {
159 pub start: NaiveDate,
160 pub end: NaiveDate,
161 pub stride_days: u32,
162 pub time_of_day: NaiveTime,
163 #[schemars(with = "String")]
164 pub time_zone: chrono_tz::Tz,
165}
166
167#[derive(
168 Debug, Clone, Copy, EnumString, IntoStaticStr, Deserialize, Serialize, JsonSchema,
169)]
170#[serde(rename_all = "snake_case")]
171#[strum(serialize_all = "snake_case")]
172pub enum OptionsExerciseType {
173 American,
174 European,
175 #[serde(other)]
176 Unknown,
177}
178
179#[cfg(feature = "postgres")]
180crate::to_sql_str!(OptionsExerciseType);
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
183#[cfg_attr(feature = "graphql", derive(juniper::GraphQLEnum))]
184pub enum PutOrCall {
185 #[serde(rename = "P")]
186 Put,
187 #[serde(rename = "C")]
188 Call,
189}
190
191impl fmt::Display for PutOrCall {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 match self {
194 Self::Put => write!(f, "P"),
195 Self::Call => write!(f, "C"),
196 }
197 }
198}
199
200impl FromStr for PutOrCall {
201 type Err = anyhow::Error;
202
203 fn from_str(s: &str) -> Result<Self> {
204 match s {
205 "P" => Ok(Self::Put),
206 "C" => Ok(Self::Call),
207 _ => bail!("invalid PutOrCall: {}", s),
208 }
209 }
210}