alpaca_data/crypto/
response.rs

1use std::collections::HashMap;
2
3use crate::{Error, transport::pagination::PaginatedResponse};
4
5use super::{Bar, Orderbook, 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 QuotesResponse {
15    pub quotes: HashMap<String, Vec<Quote>>,
16    pub next_page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
20pub struct TradesResponse {
21    pub trades: HashMap<String, Vec<Trade>>,
22    pub next_page_token: Option<String>,
23}
24
25#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
26pub struct LatestBarsResponse {
27    pub bars: HashMap<String, Bar>,
28}
29
30#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
31pub struct LatestQuotesResponse {
32    pub quotes: HashMap<String, Quote>,
33}
34
35#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
36pub struct LatestTradesResponse {
37    pub trades: HashMap<String, Trade>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
41pub struct LatestOrderbooksResponse {
42    pub orderbooks: HashMap<String, Orderbook>,
43}
44
45#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
46pub struct SnapshotsResponse {
47    pub snapshots: HashMap<String, Snapshot>,
48}
49
50fn merge_batch_page<Item>(
51    current: &mut HashMap<String, Vec<Item>>,
52    next: HashMap<String, Vec<Item>>,
53) {
54    for (symbol, mut items) in next {
55        current.entry(symbol).or_default().append(&mut items);
56    }
57}
58
59impl PaginatedResponse for BarsResponse {
60    fn next_page_token(&self) -> Option<&str> {
61        self.next_page_token.as_deref()
62    }
63
64    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
65        merge_batch_page(&mut self.bars, next.bars);
66        self.next_page_token = next.next_page_token;
67        Ok(())
68    }
69
70    fn clear_next_page_token(&mut self) {
71        self.next_page_token = None;
72    }
73}
74
75impl PaginatedResponse for QuotesResponse {
76    fn next_page_token(&self) -> Option<&str> {
77        self.next_page_token.as_deref()
78    }
79
80    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
81        merge_batch_page(&mut self.quotes, next.quotes);
82        self.next_page_token = next.next_page_token;
83        Ok(())
84    }
85
86    fn clear_next_page_token(&mut self) {
87        self.next_page_token = None;
88    }
89}
90
91impl PaginatedResponse for TradesResponse {
92    fn next_page_token(&self) -> Option<&str> {
93        self.next_page_token.as_deref()
94    }
95
96    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
97        merge_batch_page(&mut self.trades, next.trades);
98        self.next_page_token = next.next_page_token;
99        Ok(())
100    }
101
102    fn clear_next_page_token(&mut self) {
103        self.next_page_token = None;
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::{collections::HashMap, str::FromStr};
110
111    use crate::transport::pagination::PaginatedResponse;
112    use rust_decimal::Decimal;
113
114    use super::{
115        Bar, BarsResponse, LatestBarsResponse, LatestOrderbooksResponse, LatestQuotesResponse,
116        LatestTradesResponse, QuotesResponse, SnapshotsResponse, TradesResponse,
117    };
118
119    #[test]
120    fn historical_responses_deserialize_official_wrapper_shapes() {
121        let bars: BarsResponse = serde_json::from_str(
122            r#"{"bars":{"BTC/USD":[{"c":66926.1935,"h":66946.85,"l":66920.6075,"n":0,"o":66942.46,"t":"2026-04-04T00:00:00Z","v":0.02821505,"vw":66933.72875}]},"next_page_token":"page-2"}"#,
123        )
124        .expect("bars response should deserialize");
125        assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
126        assert_eq!(
127            bars.bars.get("BTC/USD").map(Vec::len).unwrap_or_default(),
128            1
129        );
130
131        let quotes: QuotesResponse = serde_json::from_str(
132            r#"{"quotes":{"BTC/USD":[{"ap":67005.5,"as":1.26733,"bp":66894.8,"bs":2.56753,"t":"2026-04-04T00:00:04.184229364Z"}]},"next_page_token":"page-3"}"#,
133        )
134        .expect("quotes response should deserialize");
135        assert_eq!(quotes.next_page_token.as_deref(), Some("page-3"));
136        assert_eq!(
137            quotes
138                .quotes
139                .get("BTC/USD")
140                .map(Vec::len)
141                .unwrap_or_default(),
142            1
143        );
144        assert_eq!(
145            quotes
146                .quotes
147                .get("BTC/USD")
148                .and_then(|quotes| quotes.first())
149                .and_then(|quote| quote.ap.as_ref())
150                .cloned(),
151            Some(Decimal::from_str("67005.5").expect("decimal literal should parse"))
152        );
153        assert_eq!(
154            quotes
155                .quotes
156                .get("BTC/USD")
157                .and_then(|quotes| quotes.first())
158                .and_then(|quote| quote.r#as.as_ref())
159                .cloned(),
160            Some(Decimal::from_str("1.26733").expect("decimal literal should parse"))
161        );
162
163        let trades: TradesResponse = serde_json::from_str(
164            r#"{"trades":{"BTC/USD":[{"i":5536632919737126473,"p":66969.687,"s":0.000073,"t":"2026-04-04T00:01:02.445726428Z","tks":"B"}]},"next_page_token":"page-4"}"#,
165        )
166        .expect("trades response should deserialize");
167        assert_eq!(trades.next_page_token.as_deref(), Some("page-4"));
168        assert_eq!(
169            trades
170                .trades
171                .get("BTC/USD")
172                .map(Vec::len)
173                .unwrap_or_default(),
174            1
175        );
176    }
177
178    #[test]
179    fn historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
180        let mut first = BarsResponse {
181            bars: HashMap::from([(
182                "BTC/USD".into(),
183                vec![Bar {
184                    t: Some("2026-04-04T00:00:00Z".into()),
185                    ..Bar::default()
186                }],
187            )]),
188            next_page_token: Some("page-2".into()),
189        };
190        let second = BarsResponse {
191            bars: HashMap::from([(
192                "ETH/USD".into(),
193                vec![Bar {
194                    t: Some("2026-04-04T00:00:00Z".into()),
195                    ..Bar::default()
196                }],
197            )]),
198            next_page_token: None,
199        };
200
201        first
202            .merge_page(second)
203            .expect("merge should combine pages without error");
204        first.clear_next_page_token();
205
206        assert_eq!(first.next_page_token, None);
207        assert_eq!(first.bars.len(), 2);
208    }
209
210    #[test]
211    fn quote_and_trade_merge_append_more_items() {
212        let mut quotes = QuotesResponse {
213            quotes: HashMap::from([(
214                "BTC/USD".into(),
215                vec![super::Quote {
216                    t: Some("2026-04-04T00:00:04.184229364Z".into()),
217                    ..super::Quote::default()
218                }],
219            )]),
220            next_page_token: Some("page-2".into()),
221        };
222        quotes
223            .merge_page(QuotesResponse {
224                quotes: HashMap::from([(
225                    "BTC/USD".into(),
226                    vec![super::Quote {
227                        t: Some("2026-04-04T00:00:05.184229364Z".into()),
228                        ..super::Quote::default()
229                    }],
230                )]),
231                next_page_token: None,
232            })
233            .expect("quote merge should append items");
234        assert_eq!(
235            quotes
236                .quotes
237                .get("BTC/USD")
238                .map(Vec::len)
239                .unwrap_or_default(),
240            2
241        );
242
243        let mut trades = TradesResponse {
244            trades: HashMap::from([(
245                "BTC/USD".into(),
246                vec![super::Trade {
247                    t: Some("2026-04-04T00:01:02.445726428Z".into()),
248                    ..super::Trade::default()
249                }],
250            )]),
251            next_page_token: Some("page-3".into()),
252        };
253        trades
254            .merge_page(TradesResponse {
255                trades: HashMap::from([(
256                    "BTC/USD".into(),
257                    vec![super::Trade {
258                        t: Some("2026-04-04T00:01:03.445726428Z".into()),
259                        ..super::Trade::default()
260                    }],
261                )]),
262                next_page_token: None,
263            })
264            .expect("trade merge should append items");
265        assert_eq!(
266            trades
267                .trades
268                .get("BTC/USD")
269                .map(Vec::len)
270                .unwrap_or_default(),
271            2
272        );
273    }
274
275    #[test]
276    fn latest_responses_deserialize_official_wrapper_shapes() {
277        let latest_bars: LatestBarsResponse = serde_json::from_str(
278            r#"{"bars":{"BTC/USD":{"c":66800.79,"h":66817.1675,"l":66800.79,"n":0,"o":66812.172,"t":"2026-04-04T04:13:00Z","v":0.0,"vw":66808.97875}}}"#,
279        )
280        .expect("latest bars response should deserialize");
281        assert!(latest_bars.bars.contains_key("BTC/USD"));
282        assert_eq!(
283            latest_bars
284                .bars
285                .get("BTC/USD")
286                .and_then(|bar| bar.c.as_ref())
287                .cloned(),
288            Some(Decimal::from_str("66800.79").expect("decimal literal should parse"))
289        );
290        assert_eq!(
291            latest_bars
292                .bars
293                .get("BTC/USD")
294                .and_then(|bar| bar.v.as_ref())
295                .map(ToString::to_string),
296            Some(
297                Decimal::from_str("0.0")
298                    .expect("decimal literal should parse")
299                    .to_string()
300            )
301        );
302        assert_eq!(
303            latest_bars
304                .bars
305                .get("BTC/USD")
306                .and_then(|bar| bar.v.as_ref())
307                .map(Decimal::scale),
308            Some(1)
309        );
310
311        let latest_quotes: LatestQuotesResponse = serde_json::from_str(
312            r#"{"quotes":{"BTC/USD":{"ap":66819.4,"as":1.28052,"bp":66763.431,"bs":1.272,"t":"2026-04-04T04:14:35.580241652Z"}}}"#,
313        )
314        .expect("latest quotes response should deserialize");
315        assert!(latest_quotes.quotes.contains_key("BTC/USD"));
316        assert_eq!(
317            latest_quotes
318                .quotes
319                .get("BTC/USD")
320                .and_then(|quote| quote.bp.as_ref())
321                .cloned(),
322            Some(Decimal::from_str("66763.431").expect("decimal literal should parse"))
323        );
324        assert_eq!(
325            latest_quotes
326                .quotes
327                .get("BTC/USD")
328                .and_then(|quote| quote.bs.as_ref())
329                .map(Decimal::scale),
330            Some(3)
331        );
332
333        let latest_trades: LatestTradesResponse = serde_json::from_str(
334            r#"{"trades":{"BTC/USD":{"i":519366231866950988,"p":66842.8,"s":0.000828,"t":"2026-04-04T04:12:55.361347989Z","tks":"B"}}}"#,
335        )
336        .expect("latest trades response should deserialize");
337        assert!(latest_trades.trades.contains_key("BTC/USD"));
338        assert_eq!(
339            latest_trades
340                .trades
341                .get("BTC/USD")
342                .and_then(|trade| trade.s.as_ref())
343                .cloned(),
344            Some(Decimal::from_str("0.000828").expect("decimal literal should parse"))
345        );
346
347        let latest_orderbooks: LatestOrderbooksResponse = serde_json::from_str(
348            r#"{"orderbooks":{"BTC/USD":{"a":[{"p":66819.4,"s":1.28052},{"p":66847.8,"s":2.5525}],"b":[{"p":66763.431,"s":1.272},{"p":66743.135,"s":2.5795}],"t":"2026-04-04T04:14:35.581059122Z"}}}"#,
349        )
350        .expect("latest orderbooks response should deserialize");
351        assert!(latest_orderbooks.orderbooks.contains_key("BTC/USD"));
352        assert_eq!(
353            latest_orderbooks
354                .orderbooks
355                .get("BTC/USD")
356                .and_then(|orderbook| orderbook.a.as_ref())
357                .and_then(|asks| asks.first())
358                .and_then(|level| level.s.as_ref())
359                .cloned(),
360            Some(Decimal::from_str("1.28052").expect("decimal literal should parse"))
361        );
362    }
363
364    #[test]
365    fn snapshots_response_deserializes_official_wrapper_shape() {
366        let snapshots: SnapshotsResponse = serde_json::from_str(
367            r#"{"snapshots":{"BTC/USD":{"dailyBar":{"c":66800.79,"h":66975.1,"l":66772.66,"n":87,"o":66942.46,"t":"2026-04-04T00:00:00Z","v":0.029938953,"vw":66854.9651939408},"latestQuote":{"ap":66819.4,"as":1.28052,"bp":66763.431,"bs":1.272,"t":"2026-04-04T04:14:35.580241652Z"},"latestTrade":{"i":7456836641300628344,"p":66832.6,"s":0.000946,"t":"2026-04-04T04:14:32.161121311Z","tks":"B"},"minuteBar":{"c":66800.79,"h":66817.1675,"l":66800.79,"n":0,"o":66812.172,"t":"2026-04-04T04:13:00Z","v":0.0,"vw":66808.97875},"prevDailyBar":{"c":66961.45,"h":67293.2523,"l":66252.479,"n":549,"o":66887.805,"t":"2026-04-03T00:00:00Z","v":1.117036142,"vw":66779.3688392417}}}}"#,
368        )
369        .expect("snapshots response should deserialize");
370
371        let snapshot = snapshots
372            .snapshots
373            .get("BTC/USD")
374            .expect("snapshot response should include the symbol");
375        assert!(snapshot.latestTrade.is_some());
376        assert!(snapshot.latestQuote.is_some());
377        assert!(snapshot.minuteBar.is_some());
378        assert!(snapshot.dailyBar.is_some());
379        assert!(snapshot.prevDailyBar.is_some());
380    }
381}