architect_api/symbology/
options_series.rs

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/// e.g. "AAPL US Options"
17#[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        // CR arao: add validation
47        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        // CR alee: check stem
110        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        // CR alee: check expiration date
118        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 we expect a venue discriminant, it must exist in the symbol and match
129        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/// A specific option from a series.
143#[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}