use super::{AlpacaOptionsClient, AlpacaOptionsError};
use crate::subscription::greeks::OptionGreeks;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::debug;
const MAX_SYMBOLS_PER_REQUEST: usize = 100;
const MAX_PAGES: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AlpacaOptionFeed {
Opra,
#[default]
Indicative,
}
impl AlpacaOptionFeed {
pub fn as_str(&self) -> &'static str {
match self {
Self::Opra => "opra",
Self::Indicative => "indicative",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlpacaOptionQuote {
#[serde(rename = "t")]
pub timestamp: DateTime<Utc>,
#[serde(rename = "ax")]
pub ask_exchange: String,
#[serde(rename = "ap")]
pub ask_price: Decimal,
#[serde(rename = "as")]
pub ask_size: u32,
#[serde(rename = "bx")]
pub bid_exchange: String,
#[serde(rename = "bp")]
pub bid_price: Decimal,
#[serde(rename = "bs")]
pub bid_size: u32,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlpacaOptionTrade {
#[serde(rename = "t")]
pub timestamp: DateTime<Utc>,
#[serde(rename = "x")]
pub exchange: String,
#[serde(rename = "p")]
pub price: Decimal,
#[serde(rename = "s")]
pub size: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
struct AlpacaGreeks {
#[serde(default)]
delta: Option<f64>,
#[serde(default)]
gamma: Option<f64>,
#[serde(default)]
theta: Option<f64>,
#[serde(default)]
vega: Option<f64>,
#[serde(default)]
rho: Option<f64>,
}
impl From<AlpacaGreeks> for OptionGreeks {
fn from(g: AlpacaGreeks) -> Self {
Self {
delta: g.delta,
gamma: g.gamma,
theta: g.theta,
vega: g.vega,
implied_volatility: None,
theoretical_price: None,
underlying_price: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlpacaOptionSnapshot {
#[serde(default)]
pub symbol: String,
#[serde(default)]
pub latest_quote: Option<AlpacaOptionQuote>,
#[serde(default)]
pub latest_trade: Option<AlpacaOptionTrade>,
#[serde(default, skip_serializing)]
greeks: Option<AlpacaGreeks>,
#[serde(default)]
pub implied_volatility: Option<f64>,
}
impl AlpacaOptionSnapshot {
pub fn greeks(&self) -> OptionGreeks {
let mut greeks: OptionGreeks = self.greeks.unwrap_or_default().into();
greeks.implied_volatility = self.implied_volatility;
greeks
}
pub fn has_greeks(&self) -> bool {
self.greeks.is_some() || self.implied_volatility.is_some()
}
}
#[derive(Debug, Deserialize)]
struct SnapshotsResponse {
snapshots: Option<HashMap<String, AlpacaOptionSnapshot>>,
#[serde(default)]
next_page_token: Option<String>,
}
impl AlpacaOptionsClient {
pub async fn fetch_snapshots(
&self,
symbols: &[String],
feed: AlpacaOptionFeed,
) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
if symbols.is_empty() {
return Ok(Vec::new());
}
let mut all_snapshots = Vec::new();
for chunk in symbols.chunks(MAX_SYMBOLS_PER_REQUEST) {
let chunk_snapshots = self.fetch_snapshots_batch(chunk, feed).await?;
all_snapshots.extend(chunk_snapshots);
}
Ok(all_snapshots)
}
async fn fetch_snapshots_batch(
&self,
symbols: &[String],
feed: AlpacaOptionFeed,
) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
let mut all_snapshots = Vec::new();
let mut page_token: Option<String> = None;
let mut pages = 0usize;
let symbols_param = symbols.join(",");
loop {
if pages >= MAX_PAGES {
debug!(
pages,
snapshots = all_snapshots.len(),
"reached max pages limit"
);
break;
}
pages += 1;
let mut params: Vec<(&str, &str)> =
vec![("symbols", &symbols_param), ("feed", feed.as_str())];
let token_string;
if let Some(ref token) = page_token {
token_string = token.clone();
params.push(("page_token", &token_string));
}
let url = format!("{}/v1beta1/options/snapshots", self.data_base);
let request = self.http.get(&url).query(¶ms);
let response: SnapshotsResponse = self.request_with_retry(request).await?;
if let Some(snapshots_map) = response.snapshots {
let count = snapshots_map.len();
all_snapshots.extend(snapshots_map.into_iter().map(|(symbol, mut snapshot)| {
snapshot.symbol = symbol;
snapshot
}));
debug!(
page = pages,
count,
total = all_snapshots.len(),
"fetched snapshots page"
);
}
match response.next_page_token {
Some(token) if !token.is_empty() => {
page_token = Some(token);
}
_ => break,
}
}
Ok(all_snapshots)
}
pub async fn fetch_chain_snapshots(
&self,
underlying: &str,
feed: AlpacaOptionFeed,
) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
use super::AlpacaOptionContractQuery;
let query = AlpacaOptionContractQuery::new(vec![underlying.to_string()]);
let contracts = self.fetch_contracts(&query).await?;
if contracts.is_empty() {
return Ok(Vec::new());
}
debug!(
underlying,
contracts = contracts.len(),
"fetching snapshots for chain"
);
let symbols: Vec<String> = contracts.iter().map(|c| c.symbol.clone()).collect();
self.fetch_snapshots(&symbols, feed).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn feed_as_str() {
assert_eq!(AlpacaOptionFeed::Opra.as_str(), "opra");
assert_eq!(AlpacaOptionFeed::Indicative.as_str(), "indicative");
}
#[test]
fn quote_deserialize() {
let json = r#"{
"t": "2024-01-15T14:27:51.742904322Z",
"ax": "C",
"ap": 2.95,
"as": 50,
"bx": "N",
"bp": 2.85,
"bs": 75
}"#;
let quote: AlpacaOptionQuote = serde_json::from_str(json).unwrap();
assert_eq!(quote.ask_exchange, "C");
assert_eq!(quote.ask_price, dec!(2.95));
assert_eq!(quote.ask_size, 50);
assert_eq!(quote.bid_exchange, "N");
assert_eq!(quote.bid_price, dec!(2.85));
assert_eq!(quote.bid_size, 75);
}
#[test]
fn trade_deserialize() {
let json = r#"{
"t": "2024-01-15T14:25:48.889796106Z",
"x": "N",
"p": 2.84,
"s": 100
}"#;
let trade: AlpacaOptionTrade = serde_json::from_str(json).unwrap();
assert_eq!(trade.exchange, "N");
assert_eq!(trade.price, dec!(2.84));
assert_eq!(trade.size, 100);
}
#[test]
fn snapshot_deserialize_full() {
let json = r#"{
"symbol": "AAPL240119C00150000",
"latest_quote": {
"t": "2024-01-15T14:27:51.742904322Z",
"ax": "C",
"ap": 2.95,
"as": 50,
"bx": "N",
"bp": 2.85,
"bs": 75
},
"latest_trade": {
"t": "2024-01-15T14:25:48.889796106Z",
"x": "N",
"p": 2.84,
"s": 100
},
"greeks": {
"delta": 0.6234,
"gamma": 0.0412,
"theta": -0.0285,
"vega": 0.3156,
"rho": 0.1829
},
"implied_volatility": 0.287
}"#;
let snapshot: AlpacaOptionSnapshot = serde_json::from_str(json).unwrap();
assert_eq!(snapshot.symbol, "AAPL240119C00150000");
assert!(snapshot.latest_quote.is_some());
assert!(snapshot.latest_trade.is_some());
assert!(snapshot.has_greeks());
let greeks = snapshot.greeks();
assert_eq!(greeks.delta, Some(0.6234));
assert_eq!(greeks.gamma, Some(0.0412));
assert_eq!(greeks.theta, Some(-0.0285));
assert_eq!(greeks.vega, Some(0.3156));
assert_eq!(greeks.implied_volatility, Some(0.287));
}
#[test]
fn snapshot_deserialize_minimal() {
let json = r#"{
"symbol": "AAPL240119C00150000"
}"#;
let snapshot: AlpacaOptionSnapshot = serde_json::from_str(json).unwrap();
assert_eq!(snapshot.symbol, "AAPL240119C00150000");
assert!(snapshot.latest_quote.is_none());
assert!(snapshot.latest_trade.is_none());
assert!(!snapshot.has_greeks());
}
#[test]
fn greeks_conversion() {
let alpaca_greeks = AlpacaGreeks {
delta: Some(0.55),
gamma: Some(0.02),
theta: Some(-0.05),
vega: Some(0.15),
rho: Some(0.10),
};
let greeks: OptionGreeks = alpaca_greeks.into();
assert_eq!(greeks.delta, Some(0.55));
assert_eq!(greeks.gamma, Some(0.02));
assert_eq!(greeks.theta, Some(-0.05));
assert_eq!(greeks.vega, Some(0.15));
assert!(greeks.implied_volatility.is_none());
}
#[test]
fn snapshots_response_deserialize() {
let json = r#"{
"snapshots": {
"AAPL240119C00150000": {
"symbol": "",
"implied_volatility": 0.25
}
},
"next_page_token": "abc123"
}"#;
let response: SnapshotsResponse = serde_json::from_str(json).unwrap();
assert!(response.snapshots.is_some());
assert_eq!(response.snapshots.unwrap().len(), 1);
assert_eq!(response.next_page_token, Some("abc123".to_string()));
}
#[test]
fn snapshots_response_empty() {
let json = r#"{}"#;
let response: SnapshotsResponse = serde_json::from_str(json).unwrap();
assert!(response.snapshots.is_none());
assert!(response.next_page_token.is_none());
}
}