Skip to main content

rustrade_data/exchange/alpaca/options/
snapshots.rs

1//! Option chain snapshots with Greeks.
2//!
3//! Implements `GET /v1beta1/options/snapshots` for fetching option snapshots with Greeks.
4
5use super::super::pagination::PaginationGuard;
6use super::{AlpacaOptionsClient, AlpacaOptionsError};
7use crate::subscription::greeks::OptionGreeks;
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use tracing::debug;
13
14/// Maximum symbols per snapshot request (Alpaca API limit).
15const MAX_SYMBOLS_PER_REQUEST: usize = 100;
16
17/// Maximum pages a single batch fetch will follow before failing with a terminal
18/// `PaginationLimitExceeded` (a runaway backstop, not a business limit).
19const MAX_PAGES: usize = 100;
20
21/// Alpaca options data feed.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum AlpacaOptionFeed {
25    /// Real-time OPRA feed (requires paid subscription).
26    Opra,
27    /// 15-minute delayed indicative feed (free).
28    #[default]
29    Indicative,
30}
31
32impl AlpacaOptionFeed {
33    /// Wire value sent in `feed=` query strings and emitted by the `Serialize` impl.
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Opra => "opra",
37            Self::Indicative => "indicative",
38        }
39    }
40}
41
42/// Option quote data (bid/ask).
43#[non_exhaustive]
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct AlpacaOptionQuote {
46    /// Quote timestamp.
47    #[serde(rename = "t")]
48    pub timestamp: DateTime<Utc>,
49    /// Ask exchange code.
50    #[serde(rename = "ax")]
51    pub ask_exchange: String,
52    /// Ask price.
53    #[serde(rename = "ap")]
54    pub ask_price: Decimal,
55    /// Ask size (number of contracts).
56    #[serde(rename = "as")]
57    pub ask_size: u32,
58    /// Bid exchange code.
59    #[serde(rename = "bx")]
60    pub bid_exchange: String,
61    /// Bid price.
62    #[serde(rename = "bp")]
63    pub bid_price: Decimal,
64    /// Bid size (number of contracts).
65    #[serde(rename = "bs")]
66    pub bid_size: u32,
67}
68
69/// Option trade data.
70#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct AlpacaOptionTrade {
73    /// Trade timestamp.
74    #[serde(rename = "t")]
75    pub timestamp: DateTime<Utc>,
76    /// Exchange code.
77    #[serde(rename = "x")]
78    pub exchange: String,
79    /// Trade price.
80    #[serde(rename = "p")]
81    pub price: Decimal,
82    /// Trade size (number of contracts).
83    #[serde(rename = "s")]
84    pub size: u32,
85}
86
87/// Greeks data from Alpaca API.
88#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
89struct AlpacaGreeks {
90    #[serde(default)]
91    delta: Option<f64>,
92    #[serde(default)]
93    gamma: Option<f64>,
94    #[serde(default)]
95    theta: Option<f64>,
96    #[serde(default)]
97    vega: Option<f64>,
98    #[serde(default)]
99    rho: Option<f64>,
100}
101
102impl From<AlpacaGreeks> for OptionGreeks {
103    fn from(g: AlpacaGreeks) -> Self {
104        Self {
105            delta: g.delta,
106            gamma: g.gamma,
107            theta: g.theta,
108            vega: g.vega,
109            rho: g.rho,
110            implied_volatility: None,
111            theoretical_price: None,
112            underlying_price: None,
113        }
114    }
115}
116
117/// Option snapshot with quote and Greeks.
118///
119/// Contains point-in-time market data for an option contract.
120#[non_exhaustive]
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct AlpacaOptionSnapshot {
123    /// OCC symbol (e.g., "AAPL240119C00150000").
124    /// Note: This is populated from the HashMap key, not deserialized from JSON.
125    #[serde(default)]
126    pub symbol: String,
127    /// Latest quote (bid/ask).
128    #[serde(default)]
129    pub latest_quote: Option<AlpacaOptionQuote>,
130    /// Latest trade.
131    #[serde(default)]
132    pub latest_trade: Option<AlpacaOptionTrade>,
133    /// Option Greeks.
134    #[serde(default, skip_serializing)]
135    greeks: Option<AlpacaGreeks>,
136    /// Implied volatility.
137    #[serde(default)]
138    pub implied_volatility: Option<f64>,
139}
140
141impl AlpacaOptionSnapshot {
142    /// Get option Greeks in the standard format.
143    ///
144    /// Returns [`OptionGreeks`] with delta, gamma, theta, vega and rho populated from the
145    /// Alpaca response. Implied volatility is stored separately in [`Self::implied_volatility`].
146    pub fn greeks(&self) -> OptionGreeks {
147        let mut greeks: OptionGreeks = self.greeks.unwrap_or_default().into();
148        greeks.implied_volatility = self.implied_volatility;
149        greeks
150    }
151
152    /// Check if this snapshot has any Greek data.
153    pub fn has_greeks(&self) -> bool {
154        self.greeks.is_some() || self.implied_volatility.is_some()
155    }
156}
157
158/// API response wrapper for snapshots.
159#[derive(Debug, Deserialize)]
160struct SnapshotsResponse {
161    snapshots: Option<HashMap<String, AlpacaOptionSnapshot>>,
162    #[serde(default)]
163    next_page_token: Option<String>,
164}
165
166impl AlpacaOptionsClient {
167    /// Fetch option snapshots with Greeks for the given symbols.
168    ///
169    /// Automatically batches requests if more than 100 symbols are provided,
170    /// and paginates through all results. Per-batch pagination is bounded: the fetch returns a
171    /// terminal [`AlpacaOptionsError::PaginationLimitExceeded`] if a batch exceeds 100 pages,
172    /// and [`AlpacaOptionsError::CyclicPagination`] if the server repeats a `page_token` — a
173    /// silently truncated result would be indistinguishable from a genuinely small one, so
174    /// incomplete pagination fails loudly instead.
175    ///
176    /// # Arguments
177    ///
178    /// * `symbols` - OCC symbols to fetch (e.g., ["AAPL240119C00150000"])
179    /// * `feed` - Data feed to use (OPRA for real-time, Indicative for delayed)
180    ///
181    /// # Returns
182    ///
183    /// Vector of option snapshots with quote and Greeks data.
184    ///
185    /// # Errors
186    ///
187    /// Returns error on network failure, API error, invalid response, or out-of-bounds
188    /// pagination (see above).
189    ///
190    /// # Note
191    ///
192    /// Greeks streaming is NOT available via WebSocket. This method provides
193    /// point-in-time snapshot data only.
194    pub async fn fetch_snapshots(
195        &self,
196        symbols: &[String],
197        feed: AlpacaOptionFeed,
198    ) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
199        if symbols.is_empty() {
200            return Ok(Vec::new());
201        }
202
203        let mut all_snapshots = Vec::new();
204
205        // Batch symbols into chunks of MAX_SYMBOLS_PER_REQUEST
206        for chunk in symbols.chunks(MAX_SYMBOLS_PER_REQUEST) {
207            let chunk_snapshots = self.fetch_snapshots_batch(chunk, feed).await?;
208            all_snapshots.extend(chunk_snapshots);
209        }
210
211        Ok(all_snapshots)
212    }
213
214    /// Fetch snapshots for a single batch of symbols (max 100).
215    async fn fetch_snapshots_batch(
216        &self,
217        symbols: &[String],
218        feed: AlpacaOptionFeed,
219    ) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
220        let mut all_snapshots = Vec::new();
221        let mut page_token: Option<String> = None;
222        let mut guard = PaginationGuard::new(MAX_PAGES);
223
224        let symbols_param = symbols.join(",");
225
226        loop {
227            guard.observe(page_token.as_deref())?;
228
229            let mut params: Vec<(&str, &str)> =
230                vec![("symbols", &symbols_param), ("feed", feed.as_str())];
231
232            // `token_string` is hoisted to outlive `params` (which borrows it).
233            let token_string;
234            if let Some(ref token) = page_token {
235                token_string = token.clone();
236                params.push(("page_token", &token_string));
237            }
238
239            let url = format!("{}/v1beta1/options/snapshots", self.rest.data_base());
240            let request = self.rest.get(&url).query(&params);
241
242            let response: SnapshotsResponse = self.rest.request_with_retry(request).await?;
243
244            if let Some(snapshots_map) = response.snapshots {
245                let count = snapshots_map.len();
246                all_snapshots.extend(snapshots_map.into_iter().map(|(symbol, mut snapshot)| {
247                    snapshot.symbol = symbol;
248                    snapshot
249                }));
250
251                debug!(
252                    page = guard.pages(),
253                    count,
254                    total = all_snapshots.len(),
255                    "fetched snapshots page"
256                );
257            }
258
259            match response.next_page_token {
260                Some(token) if !token.is_empty() => {
261                    page_token = Some(token);
262                }
263                _ => break,
264            }
265        }
266
267        Ok(all_snapshots)
268    }
269
270    /// Fetch snapshots for all options of an underlying symbol.
271    ///
272    /// This is a convenience method that first fetches all contracts for the
273    /// underlying, then fetches snapshots for those contracts.
274    ///
275    /// # Arguments
276    ///
277    /// * `underlying` - Underlying symbol (e.g., "AAPL")
278    /// * `feed` - Data feed to use
279    ///
280    /// # Returns
281    ///
282    /// Vector of option snapshots for all active contracts of the underlying.
283    pub async fn fetch_chain_snapshots(
284        &self,
285        underlying: &str,
286        feed: AlpacaOptionFeed,
287    ) -> Result<Vec<AlpacaOptionSnapshot>, AlpacaOptionsError> {
288        use super::AlpacaOptionContractQuery;
289
290        // First, fetch all contracts for this underlying
291        let query = AlpacaOptionContractQuery::new(vec![underlying.to_string()]);
292        let contracts = self.fetch_contracts(&query).await?;
293
294        if contracts.is_empty() {
295            return Ok(Vec::new());
296        }
297
298        debug!(
299            underlying,
300            contracts = contracts.len(),
301            "fetching snapshots for chain"
302        );
303
304        // Extract symbols and fetch snapshots
305        let symbols: Vec<String> = contracts.iter().map(|c| c.symbol.clone()).collect();
306        self.fetch_snapshots(&symbols, feed).await
307    }
308}
309
310#[cfg(test)]
311#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
312mod tests {
313    use super::*;
314    use rust_decimal_macros::dec;
315
316    #[test]
317    fn feed_as_str() {
318        assert_eq!(AlpacaOptionFeed::Opra.as_str(), "opra");
319        assert_eq!(AlpacaOptionFeed::Indicative.as_str(), "indicative");
320    }
321
322    #[test]
323    fn quote_deserialize() {
324        let json = r#"{
325            "t": "2024-01-15T14:27:51.742904322Z",
326            "ax": "C",
327            "ap": 2.95,
328            "as": 50,
329            "bx": "N",
330            "bp": 2.85,
331            "bs": 75
332        }"#;
333
334        let quote: AlpacaOptionQuote = serde_json::from_str(json).unwrap();
335
336        assert_eq!(quote.ask_exchange, "C");
337        assert_eq!(quote.ask_price, dec!(2.95));
338        assert_eq!(quote.ask_size, 50);
339        assert_eq!(quote.bid_exchange, "N");
340        assert_eq!(quote.bid_price, dec!(2.85));
341        assert_eq!(quote.bid_size, 75);
342    }
343
344    #[test]
345    fn trade_deserialize() {
346        let json = r#"{
347            "t": "2024-01-15T14:25:48.889796106Z",
348            "x": "N",
349            "p": 2.84,
350            "s": 100
351        }"#;
352
353        let trade: AlpacaOptionTrade = serde_json::from_str(json).unwrap();
354
355        assert_eq!(trade.exchange, "N");
356        assert_eq!(trade.price, dec!(2.84));
357        assert_eq!(trade.size, 100);
358    }
359
360    #[test]
361    fn snapshot_deserialize_full() {
362        let json = r#"{
363            "symbol": "AAPL240119C00150000",
364            "latest_quote": {
365                "t": "2024-01-15T14:27:51.742904322Z",
366                "ax": "C",
367                "ap": 2.95,
368                "as": 50,
369                "bx": "N",
370                "bp": 2.85,
371                "bs": 75
372            },
373            "latest_trade": {
374                "t": "2024-01-15T14:25:48.889796106Z",
375                "x": "N",
376                "p": 2.84,
377                "s": 100
378            },
379            "greeks": {
380                "delta": 0.6234,
381                "gamma": 0.0412,
382                "theta": -0.0285,
383                "vega": 0.3156,
384                "rho": 0.1829
385            },
386            "implied_volatility": 0.287
387        }"#;
388
389        let snapshot: AlpacaOptionSnapshot = serde_json::from_str(json).unwrap();
390
391        assert_eq!(snapshot.symbol, "AAPL240119C00150000");
392        assert!(snapshot.latest_quote.is_some());
393        assert!(snapshot.latest_trade.is_some());
394        assert!(snapshot.has_greeks());
395
396        let greeks = snapshot.greeks();
397        assert_eq!(greeks.delta, Some(0.6234));
398        assert_eq!(greeks.gamma, Some(0.0412));
399        assert_eq!(greeks.theta, Some(-0.0285));
400        assert_eq!(greeks.vega, Some(0.3156));
401        assert_eq!(greeks.rho, Some(0.1829));
402        assert_eq!(greeks.implied_volatility, Some(0.287));
403    }
404
405    #[test]
406    fn snapshot_deserialize_minimal() {
407        let json = r#"{
408            "symbol": "AAPL240119C00150000"
409        }"#;
410
411        let snapshot: AlpacaOptionSnapshot = serde_json::from_str(json).unwrap();
412
413        assert_eq!(snapshot.symbol, "AAPL240119C00150000");
414        assert!(snapshot.latest_quote.is_none());
415        assert!(snapshot.latest_trade.is_none());
416        assert!(!snapshot.has_greeks());
417    }
418
419    #[test]
420    fn greeks_conversion() {
421        let alpaca_greeks = AlpacaGreeks {
422            delta: Some(0.55),
423            gamma: Some(0.02),
424            theta: Some(-0.05),
425            vega: Some(0.15),
426            rho: Some(0.10),
427        };
428
429        let greeks: OptionGreeks = alpaca_greeks.into();
430
431        assert_eq!(greeks.delta, Some(0.55));
432        assert_eq!(greeks.gamma, Some(0.02));
433        assert_eq!(greeks.theta, Some(-0.05));
434        assert_eq!(greeks.vega, Some(0.15));
435        assert_eq!(greeks.rho, Some(0.10));
436        // implied_volatility comes from snapshot, not greeks struct
437        assert!(greeks.implied_volatility.is_none());
438    }
439
440    #[test]
441    fn snapshots_response_deserialize() {
442        let json = r#"{
443            "snapshots": {
444                "AAPL240119C00150000": {
445                    "symbol": "",
446                    "implied_volatility": 0.25
447                }
448            },
449            "next_page_token": "abc123"
450        }"#;
451
452        let response: SnapshotsResponse = serde_json::from_str(json).unwrap();
453
454        assert!(response.snapshots.is_some());
455        assert_eq!(response.snapshots.unwrap().len(), 1);
456        assert_eq!(response.next_page_token, Some("abc123".to_string()));
457    }
458
459    #[test]
460    fn snapshots_response_empty() {
461        let json = r#"{}"#;
462
463        let response: SnapshotsResponse = serde_json::from_str(json).unwrap();
464
465        assert!(response.snapshots.is_none());
466        assert!(response.next_page_token.is_none());
467    }
468}