1use std::sync::Arc;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use super::client::StreamResult;
13use super::handle::{RECONNECT_BACKOFF, SourceStream, stream_builder, stream_handle};
14use super::polygon::PolygonOptionsSource;
15use super::pricing::OptionType;
16use super::source::ReconnectConfig;
17
18const CHANNEL_CAPACITY: usize = 2048;
20
21const DEFAULT_GREEKS_REFRESH: Duration = Duration::from_secs(60);
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27#[non_exhaustive]
28pub struct Greeks {
29 pub delta: Option<f64>,
31 pub gamma: Option<f64>,
33 pub theta: Option<f64>,
35 pub vega: Option<f64>,
37}
38
39impl Greeks {
40 pub fn is_empty(&self) -> bool {
42 self.delta.is_none() && self.gamma.is_none() && self.theta.is_none() && self.vega.is_none()
43 }
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub struct OptionContractUpdate {
56 pub contract_symbol: String,
58 pub underlying: String,
60 pub expiration: Option<i64>,
62 pub strike: Option<f64>,
64 pub option_type: Option<OptionType>,
66 pub bid: Option<f64>,
68 pub bid_size: Option<f64>,
70 pub ask: Option<f64>,
72 pub ask_size: Option<f64>,
74 pub last_price: Option<f64>,
76 pub last_size: Option<f64>,
78 pub volume: Option<i64>,
80 pub open_interest: Option<i64>,
82 pub implied_volatility: Option<f64>,
84 pub greeks: Option<Greeks>,
86 pub time: i64,
88}
89
90#[derive(Clone, Debug, PartialEq)]
92pub(crate) struct ContractParts {
93 pub(crate) underlying: String,
94 pub(crate) expiration: i64,
95 pub(crate) option_type: OptionType,
96 pub(crate) strike: f64,
97}
98
99pub(crate) fn parse_contract_symbol(symbol: &str) -> Option<ContractParts> {
104 let body = symbol.strip_prefix("O:").unwrap_or(symbol);
105 if body.len() < 16 {
107 return None;
108 }
109 let split = body.len() - 15;
110 let (underlying, rest) = body.split_at(split);
111 if underlying.is_empty() || !underlying.chars().all(|c| c.is_ascii_alphanumeric()) {
112 return None;
113 }
114
115 let (date, rest) = rest.split_at(6);
116 let (kind, strike) = rest.split_at(1);
117 if !date.chars().all(|c| c.is_ascii_digit()) || !strike.chars().all(|c| c.is_ascii_digit()) {
118 return None;
119 }
120
121 let option_type = match kind {
122 "C" => OptionType::Call,
123 "P" => OptionType::Put,
124 _ => return None,
125 };
126
127 let year = 2000 + date[0..2].parse::<i32>().ok()?;
128 let month = date[2..4].parse::<u32>().ok()?;
129 let day = date[4..6].parse::<u32>().ok()?;
130 let expiration = chrono::NaiveDate::from_ymd_opt(year, month, day)?
131 .and_hms_opt(0, 0, 0)?
132 .and_utc()
133 .timestamp();
134
135 Some(ContractParts {
136 underlying: underlying.to_string(),
137 expiration,
138 option_type,
139 strike: strike.parse::<f64>().ok()? / 1000.0,
140 })
141}
142
143stream_handle! {
144 OptionsChainStream(OptionContractUpdate);
167 add: add = "Add underlyings or contracts to the subscription.",
168 remove: remove = "Remove underlyings or contracts from the subscription.",
169}
170
171impl OptionsChainStream {
172 pub async fn subscribe<S, I>(underlyings: I) -> StreamResult<Self>
174 where
175 S: Into<String>,
176 I: IntoIterator<Item = S>,
177 {
178 OptionsChainStreamBuilder::new()
179 .underlyings(underlyings)
180 .build()
181 .await
182 }
183}
184
185pub struct OptionsChainStreamBuilder {
187 underlyings: Vec<String>,
188 retry_delay: Duration,
189 max_reconnect_attempts: Option<u32>,
190 greeks_refresh: Option<Duration>,
191}
192
193impl OptionsChainStreamBuilder {
194 pub fn new() -> Self {
196 Self {
197 underlyings: Vec::new(),
198 retry_delay: RECONNECT_BACKOFF,
199 max_reconnect_attempts: None,
200 greeks_refresh: Some(DEFAULT_GREEKS_REFRESH),
201 }
202 }
203
204 pub fn greeks_refresh(mut self, interval: Option<Duration>) -> Self {
209 self.greeks_refresh = interval;
210 self
211 }
212
213 pub async fn build(self) -> StreamResult<OptionsChainStream> {
215 let source = Arc::new(PolygonOptionsSource::new(self.greeks_refresh));
216 let reconnect =
217 ReconnectConfig::new(self.retry_delay).max_attempts(self.max_reconnect_attempts);
218 Ok(OptionsChainStream {
219 inner: SourceStream::start(source, self.underlyings, reconnect, CHANNEL_CAPACITY),
220 })
221 }
222}
223
224stream_builder!(
225 OptionsChainStreamBuilder,
226 underlyings = "Add underlyings (or full OCC contract symbols) to follow."
227);
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn parses_a_call_contract_symbol() {
235 let parts = parse_contract_symbol("O:AAPL250117C00150000").expect("should parse");
236 assert_eq!(parts.underlying, "AAPL");
237 assert_eq!(parts.option_type, OptionType::Call);
238 assert!((parts.strike - 150.0).abs() < 1e-9);
239 assert_eq!(parts.expiration, 1737072000);
241 }
242
243 #[test]
244 fn parses_a_put_and_a_fractional_strike() {
245 let parts = parse_contract_symbol("O:SPY261218P00512500").expect("should parse");
246 assert_eq!(parts.underlying, "SPY");
247 assert_eq!(parts.option_type, OptionType::Put);
248 assert!((parts.strike - 512.5).abs() < 1e-9);
249 }
250
251 #[test]
252 fn parses_without_the_o_prefix() {
253 assert_eq!(
254 parse_contract_symbol("AAPL250117C00150000")
255 .unwrap()
256 .underlying,
257 "AAPL"
258 );
259 }
260
261 #[test]
262 fn rejects_malformed_symbols() {
263 for bad in [
264 "O:AAPL",
265 "AAPL250117X00150000",
266 "O:AAPL2501I7C00150000",
267 "",
268 "O:250117C00150000",
269 ] {
270 assert!(
271 parse_contract_symbol(bad).is_none(),
272 "expected {bad} to be rejected"
273 );
274 }
275 }
276
277 #[test]
278 fn greeks_report_emptiness() {
279 assert!(Greeks::default().is_empty());
280 assert!(
281 !Greeks {
282 delta: Some(0.5),
283 ..Default::default()
284 }
285 .is_empty()
286 );
287 }
288}