Skip to main content

alpaca_data/options/
response.rs

1use std::collections::HashMap;
2
3use crate::{Error, transport::pagination::PaginatedResponse};
4
5use super::{Bar, Quote, Snapshot, Trade};
6
7#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
8pub struct BarsResponse {
9    pub bars: HashMap<String, Vec<Bar>>,
10    pub next_page_token: Option<String>,
11}
12
13#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
14pub struct TradesResponse {
15    pub trades: HashMap<String, Vec<Trade>>,
16    pub next_page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
20pub struct LatestQuotesResponse {
21    pub quotes: HashMap<String, Quote>,
22}
23
24#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
25pub struct LatestTradesResponse {
26    pub trades: HashMap<String, Trade>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
30pub struct SnapshotsResponse {
31    pub snapshots: HashMap<String, Snapshot>,
32    pub next_page_token: Option<String>,
33}
34
35#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
36pub struct ChainResponse {
37    pub snapshots: HashMap<String, Snapshot>,
38    pub next_page_token: Option<String>,
39}
40
41pub type ConditionCodesResponse = HashMap<String, String>;
42
43pub type ExchangeCodesResponse = HashMap<String, String>;
44
45fn merge_batch_page<Item>(
46    current: &mut HashMap<String, Vec<Item>>,
47    next: HashMap<String, Vec<Item>>,
48) {
49    for (symbol, mut items) in next {
50        current.entry(symbol).or_default().append(&mut items);
51    }
52}
53
54fn merge_snapshot_page(
55    operation: &'static str,
56    current: &mut HashMap<String, Snapshot>,
57    next: HashMap<String, Snapshot>,
58) -> Result<(), Error> {
59    for (symbol, snapshot) in next {
60        if current.insert(symbol.clone(), snapshot).is_some() {
61            return Err(Error::Pagination(format!(
62                "{operation} received duplicate symbol across pages: {symbol}"
63            )));
64        }
65    }
66
67    Ok(())
68}
69
70impl PaginatedResponse for BarsResponse {
71    fn next_page_token(&self) -> Option<&str> {
72        self.next_page_token.as_deref()
73    }
74
75    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
76        merge_batch_page(&mut self.bars, next.bars);
77        self.next_page_token = next.next_page_token;
78        Ok(())
79    }
80
81    fn clear_next_page_token(&mut self) {
82        self.next_page_token = None;
83    }
84}
85
86impl PaginatedResponse for TradesResponse {
87    fn next_page_token(&self) -> Option<&str> {
88        self.next_page_token.as_deref()
89    }
90
91    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
92        merge_batch_page(&mut self.trades, next.trades);
93        self.next_page_token = next.next_page_token;
94        Ok(())
95    }
96
97    fn clear_next_page_token(&mut self) {
98        self.next_page_token = None;
99    }
100}
101
102impl PaginatedResponse for SnapshotsResponse {
103    fn next_page_token(&self) -> Option<&str> {
104        self.next_page_token.as_deref()
105    }
106
107    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
108        merge_snapshot_page("options.snapshots", &mut self.snapshots, next.snapshots)?;
109        self.next_page_token = next.next_page_token;
110        Ok(())
111    }
112
113    fn clear_next_page_token(&mut self) {
114        self.next_page_token = None;
115    }
116}
117
118impl PaginatedResponse for ChainResponse {
119    fn next_page_token(&self) -> Option<&str> {
120        self.next_page_token.as_deref()
121    }
122
123    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
124        merge_snapshot_page("options.chain", &mut self.snapshots, next.snapshots)?;
125        self.next_page_token = next.next_page_token;
126        Ok(())
127    }
128
129    fn clear_next_page_token(&mut self) {
130        self.next_page_token = None;
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use std::{collections::HashMap, str::FromStr};
137
138    use super::{
139        Bar, BarsResponse, ChainResponse, ConditionCodesResponse, ExchangeCodesResponse,
140        LatestQuotesResponse, LatestTradesResponse, SnapshotsResponse, Trade, TradesResponse,
141    };
142    use crate::{Error, transport::pagination::PaginatedResponse};
143    use rust_decimal::Decimal;
144
145    #[test]
146    fn historical_responses_deserialize_official_wrapper_shapes() {
147        let bars: BarsResponse = serde_json::from_str(
148            r#"{"bars":{"AAPL260406C00180000":[{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T04:00:00Z","v":2,"vw":71.09}]},"next_page_token":"page-2"}"#,
149        )
150        .expect("bars response should deserialize");
151        assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
152        assert_eq!(
153            bars.bars
154                .get("AAPL260406C00180000")
155                .map(Vec::len)
156                .unwrap_or_default(),
157            1
158        );
159        assert_eq!(
160            bars.bars
161                .get("AAPL260406C00180000")
162                .and_then(|bars| bars.first())
163                .and_then(|bar| bar.o.as_ref())
164                .map(ToString::to_string),
165            Some(Decimal::from_str("71.20").expect("decimal literal should parse"))
166                .map(|value| value.to_string())
167        );
168
169        let trades: TradesResponse = serde_json::from_str(
170            r#"{"trades":{"AAPL260406C00180000":[{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"}]},"next_page_token":"page-3"}"#,
171        )
172        .expect("trades response should deserialize");
173        assert_eq!(trades.next_page_token.as_deref(), Some("page-3"));
174        assert_eq!(
175            trades
176                .trades
177                .get("AAPL260406C00180000")
178                .map(Vec::len)
179                .unwrap_or_default(),
180            1
181        );
182        assert_eq!(
183            trades
184                .trades
185                .get("AAPL260406C00180000")
186                .and_then(|trades| trades.first())
187                .and_then(|trade| trade.p.as_ref())
188                .map(ToString::to_string),
189            Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
190                .map(|value| value.to_string())
191        );
192    }
193
194    #[test]
195    fn historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
196        let mut first = BarsResponse {
197            bars: HashMap::from([(
198                "AAPL260406C00180000".into(),
199                vec![Bar {
200                    t: Some("2026-04-02T04:00:00Z".into()),
201                    ..Bar::default()
202                }],
203            )]),
204            next_page_token: Some("page-2".into()),
205        };
206        let second = BarsResponse {
207            bars: HashMap::from([(
208                "AAPL260406C00185000".into(),
209                vec![Bar {
210                    t: Some("2026-04-02T04:00:00Z".into()),
211                    ..Bar::default()
212                }],
213            )]),
214            next_page_token: None,
215        };
216
217        first
218            .merge_page(second)
219            .expect("merge should combine pages without error");
220        first.clear_next_page_token();
221
222        assert_eq!(first.next_page_token, None);
223        assert_eq!(first.bars.len(), 2);
224    }
225
226    #[test]
227    fn historical_merge_accepts_multiple_trade_pages() {
228        let mut first = TradesResponse {
229            trades: HashMap::from([(
230                "AAPL260406C00180000".into(),
231                vec![Trade {
232                    t: Some("2026-04-02T13:39:17.488838508Z".into()),
233                    ..Trade::default()
234                }],
235            )]),
236            next_page_token: Some("page-2".into()),
237        };
238        let second = TradesResponse {
239            trades: HashMap::from([(
240                "AAPL260406C00180000".into(),
241                vec![Trade {
242                    t: Some("2026-04-02T13:39:38.883488197Z".into()),
243                    ..Trade::default()
244                }],
245            )]),
246            next_page_token: None,
247        };
248
249        first
250            .merge_page(second)
251            .expect("trade merge should append more items");
252
253        assert_eq!(
254            first
255                .trades
256                .get("AAPL260406C00180000")
257                .map(Vec::len)
258                .unwrap_or_default(),
259            2
260        );
261    }
262
263    #[test]
264    fn latest_responses_deserialize_official_wrapper_shapes() {
265        let quotes: LatestQuotesResponse = serde_json::from_str(
266            r#"{"quotes":{"AAPL260406C00180000":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}}"#,
267        )
268        .expect("latest quotes response should deserialize");
269        assert!(quotes.quotes.contains_key("AAPL260406C00180000"));
270        assert_eq!(
271            quotes
272                .quotes
273                .get("AAPL260406C00180000")
274                .and_then(|quote| quote.bp.as_ref())
275                .cloned(),
276            Some(Decimal::from_str("73.95").expect("decimal literal should parse"))
277        );
278
279        let trades: LatestTradesResponse = serde_json::from_str(
280            r#"{"trades":{"AAPL260406C00180000":{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"}}}"#,
281        )
282        .expect("latest trades response should deserialize");
283        assert!(trades.trades.contains_key("AAPL260406C00180000"));
284        assert_eq!(
285            trades
286                .trades
287                .get("AAPL260406C00180000")
288                .and_then(|trade| trade.p.as_ref())
289                .map(ToString::to_string),
290            Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
291                .map(|value| value.to_string())
292        );
293    }
294
295    #[test]
296    fn condition_codes_response_deserializes_official_map_shape() {
297        let condition_codes: ConditionCodesResponse = serde_json::from_str(
298            r#"{"a":"SLAN - Single Leg Auction Non ISO","e":"SLFT - Single Leg Floor Trade"}"#,
299        )
300        .expect("condition codes response should deserialize");
301        assert_eq!(
302            condition_codes.get("a").map(String::as_str),
303            Some("SLAN - Single Leg Auction Non ISO")
304        );
305        assert_eq!(
306            condition_codes.get("e").map(String::as_str),
307            Some("SLFT - Single Leg Floor Trade")
308        );
309    }
310
311    #[test]
312    fn exchange_codes_response_deserializes_official_map_shape() {
313        let exchange_codes: ExchangeCodesResponse = serde_json::from_str(
314            r#"{"A":"AMEX - NYSE American","O":"OPRA - Options Price Reporting Authority"}"#,
315        )
316        .expect("exchange codes response should deserialize");
317        assert_eq!(
318            exchange_codes.get("A").map(String::as_str),
319            Some("AMEX - NYSE American")
320        );
321        assert_eq!(
322            exchange_codes.get("O").map(String::as_str),
323            Some("OPRA - Options Price Reporting Authority")
324        );
325    }
326
327    #[test]
328    fn snapshot_responses_deserialize_official_wrapper_shapes() {
329        let snapshots: SnapshotsResponse = serde_json::from_str(
330            r#"{"snapshots":{"AAPL260406C00180000":{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"},"latestTrade":{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"},"minuteBar":{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T13:39:00Z","v":2,"vw":71.09},"dailyBar":{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T04:00:00Z","v":2,"vw":71.09},"prevDailyBar":{"c":72.32,"h":72.32,"l":72.32,"n":1,"o":72.32,"t":"2026-04-01T04:00:00Z","v":1,"vw":72.32},"greeks":{"delta":0.0232,"gamma":0.0118,"rho":0.0005,"theta":-0.043,"vega":0.0127},"impliedVolatility":0.2006}},"next_page_token":"page-2"}"#,
331        )
332        .expect("snapshots response should deserialize");
333        assert_eq!(snapshots.next_page_token.as_deref(), Some("page-2"));
334        let snapshot = snapshots
335            .snapshots
336            .get("AAPL260406C00180000")
337            .expect("snapshots response should include the symbol");
338        assert!(snapshot.latestQuote.is_some());
339        assert!(snapshot.latestTrade.is_some());
340        assert!(snapshot.greeks.is_some());
341        assert_eq!(
342            snapshot.impliedVolatility,
343            Some(Decimal::from_str("0.2006").expect("decimal literal should parse"))
344        );
345        assert_eq!(
346            snapshot
347                .greeks
348                .as_ref()
349                .and_then(|greeks| greeks.theta.as_ref())
350                .cloned(),
351            Some(Decimal::from_str("-0.043").expect("decimal literal should parse"))
352        );
353        assert_eq!(
354            snapshot
355                .latestQuote
356                .as_ref()
357                .and_then(|quote| quote.bp.as_ref())
358                .cloned(),
359            Some(Decimal::from_str("73.95").expect("decimal literal should parse"))
360        );
361        assert_eq!(
362            snapshot
363                .latestTrade
364                .as_ref()
365                .and_then(|trade| trade.p.as_ref())
366                .map(ToString::to_string),
367            Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
368                .map(|value| value.to_string())
369        );
370        assert_eq!(
371            snapshot
372                .minuteBar
373                .as_ref()
374                .and_then(|bar| bar.o.as_ref())
375                .map(ToString::to_string),
376            Some(Decimal::from_str("71.20").expect("decimal literal should parse"))
377                .map(|value| value.to_string())
378        );
379
380        let chain: ChainResponse = serde_json::from_str(
381            r#"{"snapshots":{"AAPL260406C00185000":{"latestQuote":{"ap":72.85,"as":5,"ax":"X","bp":69.1,"bs":4,"bx":"S","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}},"next_page_token":null}"#,
382        )
383        .expect("chain response should deserialize");
384        assert!(chain.snapshots.contains_key("AAPL260406C00185000"));
385        assert_eq!(chain.next_page_token, None);
386    }
387
388    #[test]
389    fn snapshot_merge_combines_distinct_symbol_pages_and_clears_next_page_token() {
390        let mut first = SnapshotsResponse {
391            snapshots: HashMap::from([(
392                "AAPL260406C00180000".into(),
393                serde_json::from_str(
394                    r#"{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
395                )
396                .expect("first snapshot should deserialize"),
397            )]),
398            next_page_token: Some("page-2".into()),
399        };
400        let second = SnapshotsResponse {
401            snapshots: HashMap::from([(
402                "AAPL260406C00185000".into(),
403                serde_json::from_str(
404                    r#"{"latestQuote":{"ap":72.85,"as":5,"ax":"X","bp":69.1,"bs":4,"bx":"S","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
405                )
406                .expect("second snapshot should deserialize"),
407            )]),
408            next_page_token: None,
409        };
410
411        first
412            .merge_page(second)
413            .expect("snapshots merge should combine distinct symbols");
414        first.clear_next_page_token();
415
416        assert_eq!(first.next_page_token, None);
417        assert_eq!(first.snapshots.len(), 2);
418    }
419
420    #[test]
421    fn snapshot_merge_rejects_duplicate_symbols_across_pages() {
422        let mut first = ChainResponse {
423            snapshots: HashMap::from([(
424                "AAPL260406C00180000".into(),
425                serde_json::from_str(
426                    r#"{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
427                )
428                .expect("first snapshot should deserialize"),
429            )]),
430            next_page_token: Some("page-2".into()),
431        };
432        let second = ChainResponse {
433            snapshots: HashMap::from([(
434                "AAPL260406C00180000".into(),
435                serde_json::from_str(
436                    r#"{"latestQuote":{"ap":78.00,"as":1,"ax":"B","bp":74.00,"bs":2,"bx":"A","c":" ","t":"2026-04-02T20:00:00Z"}}"#,
437                )
438                .expect("duplicate snapshot should deserialize"),
439            )]),
440            next_page_token: None,
441        };
442
443        let error = first
444            .merge_page(second)
445            .expect_err("duplicate symbols should be rejected");
446        assert!(matches!(error, Error::Pagination(_)));
447    }
448}