use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::client::StreamResult;
use super::handle::{RECONNECT_BACKOFF, SourceStream, stream_builder, stream_handle};
use super::polygon::PolygonOptionsSource;
use super::pricing::OptionType;
use super::source::ReconnectConfig;
const CHANNEL_CAPACITY: usize = 2048;
const DEFAULT_GREEKS_REFRESH: Duration = Duration::from_secs(60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Greeks {
pub delta: Option<f64>,
pub gamma: Option<f64>,
pub theta: Option<f64>,
pub vega: Option<f64>,
}
impl Greeks {
pub fn is_empty(&self) -> bool {
self.delta.is_none() && self.gamma.is_none() && self.theta.is_none() && self.vega.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OptionContractUpdate {
pub contract_symbol: String,
pub underlying: String,
pub expiration: Option<i64>,
pub strike: Option<f64>,
pub option_type: Option<OptionType>,
pub bid: Option<f64>,
pub bid_size: Option<f64>,
pub ask: Option<f64>,
pub ask_size: Option<f64>,
pub last_price: Option<f64>,
pub last_size: Option<f64>,
pub volume: Option<i64>,
pub open_interest: Option<i64>,
pub implied_volatility: Option<f64>,
pub greeks: Option<Greeks>,
pub time: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ContractParts {
pub(crate) underlying: String,
pub(crate) expiration: i64,
pub(crate) option_type: OptionType,
pub(crate) strike: f64,
}
pub(crate) fn parse_contract_symbol(symbol: &str) -> Option<ContractParts> {
let body = symbol.strip_prefix("O:").unwrap_or(symbol);
if body.len() < 16 {
return None;
}
let split = body.len() - 15;
let (underlying, rest) = body.split_at(split);
if underlying.is_empty() || !underlying.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
let (date, rest) = rest.split_at(6);
let (kind, strike) = rest.split_at(1);
if !date.chars().all(|c| c.is_ascii_digit()) || !strike.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let option_type = match kind {
"C" => OptionType::Call,
"P" => OptionType::Put,
_ => return None,
};
let year = 2000 + date[0..2].parse::<i32>().ok()?;
let month = date[2..4].parse::<u32>().ok()?;
let day = date[4..6].parse::<u32>().ok()?;
let expiration = chrono::NaiveDate::from_ymd_opt(year, month, day)?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp();
Some(ContractParts {
underlying: underlying.to_string(),
expiration,
option_type,
strike: strike.parse::<f64>().ok()? / 1000.0,
})
}
stream_handle! {
OptionsChainStream(OptionContractUpdate);
add: add = "Add underlyings or contracts to the subscription.",
remove: remove = "Remove underlyings or contracts from the subscription.",
}
impl OptionsChainStream {
pub async fn subscribe<S, I>(underlyings: I) -> StreamResult<Self>
where
S: Into<String>,
I: IntoIterator<Item = S>,
{
OptionsChainStreamBuilder::new()
.underlyings(underlyings)
.build()
.await
}
}
pub struct OptionsChainStreamBuilder {
underlyings: Vec<String>,
retry_delay: Duration,
max_reconnect_attempts: Option<u32>,
greeks_refresh: Option<Duration>,
}
impl OptionsChainStreamBuilder {
pub fn new() -> Self {
Self {
underlyings: Vec::new(),
retry_delay: RECONNECT_BACKOFF,
max_reconnect_attempts: None,
greeks_refresh: Some(DEFAULT_GREEKS_REFRESH),
}
}
pub fn greeks_refresh(mut self, interval: Option<Duration>) -> Self {
self.greeks_refresh = interval;
self
}
pub async fn build(self) -> StreamResult<OptionsChainStream> {
let source = Arc::new(PolygonOptionsSource::new(self.greeks_refresh));
let reconnect =
ReconnectConfig::new(self.retry_delay).max_attempts(self.max_reconnect_attempts);
Ok(OptionsChainStream {
inner: SourceStream::start(source, self.underlyings, reconnect, CHANNEL_CAPACITY),
})
}
}
stream_builder!(
OptionsChainStreamBuilder,
underlyings = "Add underlyings (or full OCC contract symbols) to follow."
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_call_contract_symbol() {
let parts = parse_contract_symbol("O:AAPL250117C00150000").expect("should parse");
assert_eq!(parts.underlying, "AAPL");
assert_eq!(parts.option_type, OptionType::Call);
assert!((parts.strike - 150.0).abs() < 1e-9);
assert_eq!(parts.expiration, 1737072000);
}
#[test]
fn parses_a_put_and_a_fractional_strike() {
let parts = parse_contract_symbol("O:SPY261218P00512500").expect("should parse");
assert_eq!(parts.underlying, "SPY");
assert_eq!(parts.option_type, OptionType::Put);
assert!((parts.strike - 512.5).abs() < 1e-9);
}
#[test]
fn parses_without_the_o_prefix() {
assert_eq!(
parse_contract_symbol("AAPL250117C00150000")
.unwrap()
.underlying,
"AAPL"
);
}
#[test]
fn rejects_malformed_symbols() {
for bad in [
"O:AAPL",
"AAPL250117X00150000",
"O:AAPL2501I7C00150000",
"",
"O:250117C00150000",
] {
assert!(
parse_contract_symbol(bad).is_none(),
"expected {bad} to be rejected"
);
}
}
#[test]
fn greeks_report_emptiness() {
assert!(Greeks::default().is_empty());
assert!(
!Greeks {
delta: Some(0.5),
..Default::default()
}
.is_empty()
);
}
}