alpaca_data/stocks/
response.rs

1use std::collections::HashMap;
2
3use crate::{Error, transport::pagination::PaginatedResponse};
4
5use super::{Bar, Currency, DailyAuction, 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    pub currency: Option<Currency>,
12}
13
14#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
15pub struct BarsSingleResponse {
16    pub symbol: String,
17    pub bars: Vec<Bar>,
18    pub next_page_token: Option<String>,
19    pub currency: Option<Currency>,
20}
21
22#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
23pub struct AuctionsResponse {
24    pub auctions: HashMap<String, Vec<DailyAuction>>,
25    pub next_page_token: Option<String>,
26    pub currency: Option<Currency>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
30pub struct AuctionsSingleResponse {
31    pub symbol: String,
32    pub auctions: Vec<DailyAuction>,
33    pub next_page_token: Option<String>,
34    pub currency: Option<Currency>,
35}
36
37#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
38pub struct QuotesResponse {
39    pub quotes: HashMap<String, Vec<Quote>>,
40    pub next_page_token: Option<String>,
41    pub currency: Option<Currency>,
42}
43
44#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
45pub struct TradesResponse {
46    pub trades: HashMap<String, Vec<Trade>>,
47    pub next_page_token: Option<String>,
48    pub currency: Option<Currency>,
49}
50
51#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
52pub struct QuotesSingleResponse {
53    pub symbol: String,
54    pub quotes: Vec<Quote>,
55    pub next_page_token: Option<String>,
56    pub currency: Option<Currency>,
57}
58
59#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
60pub struct TradesSingleResponse {
61    pub symbol: String,
62    pub trades: Vec<Trade>,
63    pub next_page_token: Option<String>,
64    pub currency: Option<Currency>,
65}
66
67#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
68pub struct LatestBarsResponse {
69    pub bars: HashMap<String, Bar>,
70    pub currency: Option<Currency>,
71}
72
73#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
74pub struct LatestBarResponse {
75    pub symbol: String,
76    pub bar: Bar,
77    pub currency: Option<Currency>,
78}
79
80#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
81pub struct LatestQuotesResponse {
82    pub quotes: HashMap<String, Quote>,
83    pub currency: Option<Currency>,
84}
85
86#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
87pub struct LatestQuoteResponse {
88    pub symbol: String,
89    pub quote: Quote,
90    pub currency: Option<Currency>,
91}
92
93#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
94pub struct LatestTradesResponse {
95    pub trades: HashMap<String, Trade>,
96    pub currency: Option<Currency>,
97}
98
99#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
100pub struct LatestTradeResponse {
101    pub symbol: String,
102    pub trade: Trade,
103    pub currency: Option<Currency>,
104}
105
106pub type SnapshotsResponse = HashMap<String, Snapshot>;
107
108#[allow(non_snake_case)]
109#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
110pub struct SnapshotResponse {
111    pub symbol: String,
112    pub currency: Option<Currency>,
113    pub latestTrade: Option<Trade>,
114    pub latestQuote: Option<Quote>,
115    pub minuteBar: Option<Bar>,
116    pub dailyBar: Option<Bar>,
117    pub prevDailyBar: Option<Bar>,
118}
119
120pub type ConditionCodesResponse = HashMap<String, String>;
121
122pub type ExchangeCodesResponse = HashMap<String, String>;
123
124fn merge_batch_currency(
125    operation: &'static str,
126    currency: &mut Option<Currency>,
127    next_currency: Option<Currency>,
128) -> Result<(), Error> {
129    match (currency.as_ref(), next_currency) {
130        (Some(current), Some(next)) if current != &next => Err(Error::Pagination(format!(
131            "{operation} received mismatched currency across pages: expected {}, got {}",
132            current.as_str(),
133            next.as_str()
134        ))),
135        (None, Some(next)) => {
136            *currency = Some(next);
137            Ok(())
138        }
139        _ => Ok(()),
140    }
141}
142
143fn merge_batch_page<Item>(
144    current: &mut HashMap<String, Vec<Item>>,
145    next: HashMap<String, Vec<Item>>,
146) {
147    for (symbol, mut items) in next {
148        current.entry(symbol).or_default().append(&mut items);
149    }
150}
151
152fn merge_single_metadata(
153    operation: &'static str,
154    symbol: &mut String,
155    currency: &mut Option<Currency>,
156    next_symbol: String,
157    next_currency: Option<Currency>,
158) -> Result<(), Error> {
159    if !symbol.is_empty() && *symbol != next_symbol {
160        return Err(Error::Pagination(format!(
161            "{operation} received mismatched symbol across pages: expected {}, got {}",
162            symbol, next_symbol
163        )));
164    }
165
166    if symbol.is_empty() {
167        *symbol = next_symbol;
168    }
169
170    match (currency.as_ref(), next_currency) {
171        (Some(current), Some(next)) if current != &next => Err(Error::Pagination(format!(
172            "{operation} received mismatched currency across pages: expected {}, got {}",
173            current.as_str(),
174            next.as_str()
175        ))),
176        (None, Some(next)) => {
177            *currency = Some(next);
178            Ok(())
179        }
180        _ => Ok(()),
181    }
182}
183
184impl PaginatedResponse for BarsSingleResponse {
185    fn next_page_token(&self) -> Option<&str> {
186        self.next_page_token.as_deref()
187    }
188
189    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
190        merge_single_metadata(
191            "stocks.bars_single_all",
192            &mut self.symbol,
193            &mut self.currency,
194            next.symbol,
195            next.currency,
196        )?;
197        self.bars.extend(next.bars);
198        self.next_page_token = next.next_page_token;
199        Ok(())
200    }
201
202    fn clear_next_page_token(&mut self) {
203        self.next_page_token = None;
204    }
205}
206
207impl PaginatedResponse for BarsResponse {
208    fn next_page_token(&self) -> Option<&str> {
209        self.next_page_token.as_deref()
210    }
211
212    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
213        merge_batch_currency("stocks.bars_all", &mut self.currency, next.currency)?;
214        merge_batch_page(&mut self.bars, next.bars);
215        self.next_page_token = next.next_page_token;
216        Ok(())
217    }
218
219    fn clear_next_page_token(&mut self) {
220        self.next_page_token = None;
221    }
222}
223
224impl PaginatedResponse for AuctionsSingleResponse {
225    fn next_page_token(&self) -> Option<&str> {
226        self.next_page_token.as_deref()
227    }
228
229    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
230        merge_single_metadata(
231            "stocks.auctions_single_all",
232            &mut self.symbol,
233            &mut self.currency,
234            next.symbol,
235            next.currency,
236        )?;
237        self.auctions.extend(next.auctions);
238        self.next_page_token = next.next_page_token;
239        Ok(())
240    }
241
242    fn clear_next_page_token(&mut self) {
243        self.next_page_token = None;
244    }
245}
246
247impl PaginatedResponse for AuctionsResponse {
248    fn next_page_token(&self) -> Option<&str> {
249        self.next_page_token.as_deref()
250    }
251
252    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
253        merge_batch_currency("stocks.auctions_all", &mut self.currency, next.currency)?;
254        merge_batch_page(&mut self.auctions, next.auctions);
255        self.next_page_token = next.next_page_token;
256        Ok(())
257    }
258
259    fn clear_next_page_token(&mut self) {
260        self.next_page_token = None;
261    }
262}
263
264impl PaginatedResponse for QuotesSingleResponse {
265    fn next_page_token(&self) -> Option<&str> {
266        self.next_page_token.as_deref()
267    }
268
269    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
270        merge_single_metadata(
271            "stocks.quotes_single_all",
272            &mut self.symbol,
273            &mut self.currency,
274            next.symbol,
275            next.currency,
276        )?;
277        self.quotes.extend(next.quotes);
278        self.next_page_token = next.next_page_token;
279        Ok(())
280    }
281
282    fn clear_next_page_token(&mut self) {
283        self.next_page_token = None;
284    }
285}
286
287impl PaginatedResponse for QuotesResponse {
288    fn next_page_token(&self) -> Option<&str> {
289        self.next_page_token.as_deref()
290    }
291
292    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
293        merge_batch_currency("stocks.quotes_all", &mut self.currency, next.currency)?;
294        merge_batch_page(&mut self.quotes, next.quotes);
295        self.next_page_token = next.next_page_token;
296        Ok(())
297    }
298
299    fn clear_next_page_token(&mut self) {
300        self.next_page_token = None;
301    }
302}
303
304impl PaginatedResponse for TradesSingleResponse {
305    fn next_page_token(&self) -> Option<&str> {
306        self.next_page_token.as_deref()
307    }
308
309    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
310        merge_single_metadata(
311            "stocks.trades_single_all",
312            &mut self.symbol,
313            &mut self.currency,
314            next.symbol,
315            next.currency,
316        )?;
317        self.trades.extend(next.trades);
318        self.next_page_token = next.next_page_token;
319        Ok(())
320    }
321
322    fn clear_next_page_token(&mut self) {
323        self.next_page_token = None;
324    }
325}
326
327impl PaginatedResponse for TradesResponse {
328    fn next_page_token(&self) -> Option<&str> {
329        self.next_page_token.as_deref()
330    }
331
332    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
333        merge_batch_currency("stocks.trades_all", &mut self.currency, next.currency)?;
334        merge_batch_page(&mut self.trades, next.trades);
335        self.next_page_token = next.next_page_token;
336        Ok(())
337    }
338
339    fn clear_next_page_token(&mut self) {
340        self.next_page_token = None;
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use std::collections::HashMap;
347
348    use super::{
349        AuctionsResponse, AuctionsSingleResponse, BarsResponse, BarsSingleResponse,
350        ConditionCodesResponse, ExchangeCodesResponse, LatestBarResponse, LatestBarsResponse,
351        LatestQuoteResponse, LatestQuotesResponse, LatestTradeResponse, LatestTradesResponse,
352        QuotesSingleResponse, SnapshotResponse, SnapshotsResponse, TradesSingleResponse,
353    };
354    use crate::{Error, transport::pagination::PaginatedResponse};
355
356    #[test]
357    fn single_historical_responses_deserialize_official_wrapper_fields() {
358        let auctions: AuctionsSingleResponse = serde_json::from_str(
359            r#"{"symbol":"AAPL","auctions":[],"next_page_token":"page-1","currency":"USD"}"#,
360        )
361        .expect("auctions single response should deserialize");
362        assert_eq!(auctions.symbol, "AAPL");
363        assert_eq!(auctions.next_page_token.as_deref(), Some("page-1"));
364        assert_eq!(
365            auctions.currency.as_ref().map(|value| value.as_str()),
366            Some("USD")
367        );
368
369        let bars: BarsSingleResponse = serde_json::from_str(
370            r#"{"symbol":"AAPL","bars":[],"next_page_token":"page-2","currency":"USD"}"#,
371        )
372        .expect("bars single response should deserialize");
373        assert_eq!(bars.symbol, "AAPL");
374        assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
375        assert_eq!(
376            bars.currency.as_ref().map(|value| value.as_str()),
377            Some("USD")
378        );
379
380        let quotes: QuotesSingleResponse = serde_json::from_str(
381            r#"{"symbol":"AAPL","quotes":[],"next_page_token":"page-3","currency":"USD"}"#,
382        )
383        .expect("quotes single response should deserialize");
384        assert_eq!(quotes.symbol, "AAPL");
385        assert_eq!(quotes.next_page_token.as_deref(), Some("page-3"));
386        assert_eq!(
387            quotes.currency.as_ref().map(|value| value.as_str()),
388            Some("USD")
389        );
390
391        let trades: TradesSingleResponse = serde_json::from_str(
392            r#"{"symbol":"AAPL","trades":[],"next_page_token":"page-4","currency":"USD"}"#,
393        )
394        .expect("trades single response should deserialize");
395        assert_eq!(trades.symbol, "AAPL");
396        assert_eq!(trades.next_page_token.as_deref(), Some("page-4"));
397        assert_eq!(
398            trades.currency.as_ref().map(|value| value.as_str()),
399            Some("USD")
400        );
401    }
402
403    #[test]
404    fn single_historical_merge_preserves_symbol_and_currency() {
405        let mut auctions = AuctionsSingleResponse {
406            symbol: "AAPL".into(),
407            auctions: vec![],
408            next_page_token: Some("page-2".into()),
409            currency: Some("USD".into()),
410        };
411
412        auctions
413            .merge_page(AuctionsSingleResponse {
414                symbol: "AAPL".into(),
415                auctions: vec![],
416                next_page_token: None,
417                currency: Some("USD".into()),
418            })
419            .expect("matching auction pages should merge");
420
421        assert_eq!(auctions.symbol, "AAPL");
422        assert_eq!(
423            auctions.currency.as_ref().map(|value| value.as_str()),
424            Some("USD")
425        );
426        assert_eq!(auctions.next_page_token, None);
427
428        let mut first = BarsSingleResponse {
429            symbol: "AAPL".into(),
430            bars: vec![],
431            next_page_token: Some("page-2".into()),
432            currency: Some("USD".into()),
433        };
434
435        first
436            .merge_page(BarsSingleResponse {
437                symbol: "AAPL".into(),
438                bars: vec![],
439                next_page_token: None,
440                currency: Some("USD".into()),
441            })
442            .expect("matching pages should merge");
443
444        assert_eq!(first.symbol, "AAPL");
445        assert_eq!(
446            first.currency.as_ref().map(|value| value.as_str()),
447            Some("USD")
448        );
449        assert_eq!(first.next_page_token, None);
450    }
451
452    #[test]
453    fn single_historical_merge_rejects_mismatched_symbol_or_currency() {
454        let mut auctions_symbol_mismatch = AuctionsSingleResponse {
455            symbol: "AAPL".into(),
456            auctions: vec![],
457            next_page_token: Some("page-2".into()),
458            currency: Some("USD".into()),
459        };
460
461        let auctions_symbol_error = auctions_symbol_mismatch
462            .merge_page(AuctionsSingleResponse {
463                symbol: "MSFT".into(),
464                auctions: vec![],
465                next_page_token: None,
466                currency: Some("USD".into()),
467            })
468            .expect_err("mismatched auction symbols should fail");
469        assert!(matches!(auctions_symbol_error, Error::Pagination(_)));
470
471        let mut symbol_mismatch = BarsSingleResponse {
472            symbol: "AAPL".into(),
473            bars: vec![],
474            next_page_token: Some("page-2".into()),
475            currency: Some("USD".into()),
476        };
477
478        let symbol_error = symbol_mismatch
479            .merge_page(BarsSingleResponse {
480                symbol: "MSFT".into(),
481                bars: vec![],
482                next_page_token: None,
483                currency: Some("USD".into()),
484            })
485            .expect_err("mismatched symbols should fail");
486        assert!(matches!(symbol_error, Error::Pagination(_)));
487
488        let mut currency_mismatch = BarsSingleResponse {
489            symbol: "AAPL".into(),
490            bars: vec![],
491            next_page_token: Some("page-2".into()),
492            currency: Some("USD".into()),
493        };
494
495        let currency_error = currency_mismatch
496            .merge_page(BarsSingleResponse {
497                symbol: "AAPL".into(),
498                bars: vec![],
499                next_page_token: None,
500                currency: Some("CAD".into()),
501            })
502            .expect_err("mismatched currencies should fail");
503        assert!(matches!(currency_error, Error::Pagination(_)));
504    }
505
506    #[test]
507    fn latest_responses_deserialize_official_wrapper_shapes() {
508        let batch_bars: LatestBarsResponse = serde_json::from_str(
509            r#"{"bars":{"AAPL":{"t":"2024-03-01T20:00:00Z","c":179.66}},"currency":"USD"}"#,
510        )
511        .expect("latest bars response should deserialize");
512        assert!(batch_bars.bars.contains_key("AAPL"));
513        assert_eq!(
514            batch_bars.currency.as_ref().map(|value| value.as_str()),
515            Some("USD")
516        );
517
518        let single_bar: LatestBarResponse = serde_json::from_str(
519            r#"{"symbol":"AAPL","bar":{"t":"2024-03-01T20:00:00Z","c":179.66},"currency":"USD"}"#,
520        )
521        .expect("latest bar response should deserialize");
522        assert_eq!(single_bar.symbol, "AAPL");
523        assert!(single_bar.bar.c.is_some());
524
525        let batch_quotes: LatestQuotesResponse = serde_json::from_str(
526            r#"{"quotes":{"AAPL":{"t":"2024-03-01T20:00:00Z","bp":179.65}},"currency":"USD"}"#,
527        )
528        .expect("latest quotes response should deserialize");
529        assert!(batch_quotes.quotes.contains_key("AAPL"));
530
531        let single_quote: LatestQuoteResponse = serde_json::from_str(
532            r#"{"symbol":"AAPL","quote":{"t":"2024-03-01T20:00:00Z","bp":179.65},"currency":"USD"}"#,
533        )
534        .expect("latest quote response should deserialize");
535        assert_eq!(single_quote.symbol, "AAPL");
536        assert!(single_quote.quote.bp.is_some());
537
538        let batch_trades: LatestTradesResponse = serde_json::from_str(
539            r#"{"trades":{"AAPL":{"t":"2024-03-01T20:00:00Z","p":179.64}},"currency":"USD"}"#,
540        )
541        .expect("latest trades response should deserialize");
542        assert!(batch_trades.trades.contains_key("AAPL"));
543
544        let single_trade: LatestTradeResponse = serde_json::from_str(
545            r#"{"symbol":"AAPL","trade":{"t":"2024-03-01T20:00:00Z","p":179.64},"currency":"USD"}"#,
546        )
547        .expect("latest trade response should deserialize");
548        assert_eq!(single_trade.symbol, "AAPL");
549        assert!(single_trade.trade.p.is_some());
550    }
551
552    #[test]
553    fn snapshot_responses_deserialize_official_batch_and_single_shapes() {
554        let batch: SnapshotsResponse = serde_json::from_str(
555            r#"{
556                "AAPL":{
557                    "latestTrade":{"t":"2024-03-01T20:00:00Z","p":179.64},
558                    "latestQuote":{"t":"2024-03-01T20:00:00Z","bp":179.65},
559                    "minuteBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
560                    "dailyBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
561                    "prevDailyBar":{"t":"2024-02-29T20:00:00Z","c":180.75}
562                }
563            }"#,
564        )
565        .expect("batch snapshots response should deserialize");
566        let aapl = batch
567            .get("AAPL")
568            .expect("batch snapshots response should keep the symbol as the top-level key");
569        assert!(aapl.latestTrade.is_some());
570        assert!(aapl.latestQuote.is_some());
571        assert!(aapl.minuteBar.is_some());
572        assert!(aapl.dailyBar.is_some());
573        assert!(aapl.prevDailyBar.is_some());
574
575        let single: SnapshotResponse = serde_json::from_str(
576            r#"{
577                "symbol":"AAPL",
578                "currency":"USD",
579                "latestTrade":{"t":"2024-03-01T20:00:00Z","p":179.64},
580                "latestQuote":{"t":"2024-03-01T20:00:00Z","bp":179.65},
581                "minuteBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
582                "dailyBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
583                "prevDailyBar":{"t":"2024-02-29T20:00:00Z","c":180.75}
584            }"#,
585        )
586        .expect("single snapshot response should deserialize");
587        assert_eq!(single.symbol, "AAPL");
588        assert_eq!(
589            single.currency.as_ref().map(|value| value.as_str()),
590            Some("USD")
591        );
592        assert!(single.latestTrade.is_some());
593        assert!(single.latestQuote.is_some());
594        assert!(single.minuteBar.is_some());
595        assert!(single.dailyBar.is_some());
596        assert!(single.prevDailyBar.is_some());
597    }
598
599    #[test]
600    fn metadata_responses_deserialize_official_map_shapes() {
601        let condition_codes: ConditionCodesResponse =
602            serde_json::from_str(r#"{" ":"Regular Sale","4":"Derivatively Priced"}"#)
603                .expect("condition codes should deserialize as a top-level map");
604        assert_eq!(
605            condition_codes.get(" ").map(String::as_str),
606            Some("Regular Sale")
607        );
608        assert_eq!(
609            condition_codes.get("4").map(String::as_str),
610            Some("Derivatively Priced")
611        );
612
613        let exchange_codes: ExchangeCodesResponse =
614            serde_json::from_str(r#"{"V":"IEX","N":"New York Stock Exchange"}"#)
615                .expect("exchange codes should deserialize as a top-level map");
616        assert_eq!(exchange_codes.get("V").map(String::as_str), Some("IEX"));
617        assert_eq!(
618            exchange_codes.get("N").map(String::as_str),
619            Some("New York Stock Exchange")
620        );
621    }
622
623    #[test]
624    fn auctions_responses_deserialize_official_wrapper_shapes() {
625        let batch: AuctionsResponse = serde_json::from_str(
626            r#"{"auctions":{"AAPL":[{"d":"2024-03-01","o":[{"c":"Q","p":179.55,"s":8,"t":"2024-03-01T14:30:00.092366196Z","x":"P"}],"c":[{"c":"M","p":179.64,"s":2008,"t":"2024-03-01T21:00:00.071062102Z","x":"P"}]}]},"next_page_token":"page-2","currency":"USD"}"#,
627        )
628        .expect("batch auctions response should deserialize");
629        assert_eq!(batch.next_page_token.as_deref(), Some("page-2"));
630        assert_eq!(
631            batch.currency.as_ref().map(|value| value.as_str()),
632            Some("USD")
633        );
634        assert_eq!(batch.auctions.get("AAPL").map(Vec::len), Some(1));
635
636        let single: AuctionsSingleResponse = serde_json::from_str(
637            r#"{"symbol":"AAPL","auctions":[{"d":"2024-03-01","o":[{"c":"Q","p":179.55,"s":8,"t":"2024-03-01T14:30:00.092366196Z","x":"P"}],"c":[{"c":"M","p":179.64,"s":2008,"t":"2024-03-01T21:00:00.071062102Z","x":"P"}]}],"next_page_token":null,"currency":"USD"}"#,
638        )
639        .expect("single auctions response should deserialize");
640        assert_eq!(single.symbol, "AAPL");
641        assert_eq!(single.auctions.len(), 1);
642    }
643
644    #[test]
645    fn auctions_batch_merge_combines_symbol_buckets_and_clears_next_page_token() {
646        let mut first = AuctionsResponse {
647            auctions: HashMap::from([(
648                "AAPL".into(),
649                vec![serde_json::from_str(
650                    r#"{"d":"2024-03-01","o":[{"c":"Q","p":179.55,"t":"2024-03-01T14:30:00.092366196Z","x":"P"}],"c":[{"c":"M","p":179.64,"t":"2024-03-01T21:00:00.071062102Z","x":"P"}]}"#,
651                )
652                .expect("daily auction should deserialize")],
653            )]),
654            next_page_token: Some("page-2".into()),
655            currency: Some("USD".into()),
656        };
657
658        first
659            .merge_page(AuctionsResponse {
660                auctions: HashMap::from([(
661                    "MSFT".into(),
662                    vec![serde_json::from_str(
663                        r#"{"d":"2024-03-01","o":[{"c":"Q","p":415.10,"t":"2024-03-01T14:30:00.100000000Z","x":"P"}],"c":[{"c":"M","p":415.20,"t":"2024-03-01T21:00:00.100000000Z","x":"P"}]}"#,
664                    )
665                    .expect("daily auction should deserialize")],
666                )]),
667                next_page_token: None,
668                currency: Some("USD".into()),
669            })
670            .expect("matching auction currencies should merge");
671
672        assert_eq!(first.auctions.get("AAPL").map(Vec::len), Some(1));
673        assert_eq!(first.auctions.get("MSFT").map(Vec::len), Some(1));
674        assert_eq!(first.next_page_token, None);
675    }
676
677    #[test]
678    fn batch_historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
679        let mut first = BarsResponse {
680            bars: HashMap::from([
681                (
682                    "AAPL".into(),
683                    vec![
684                        serde_json::from_str(r#"{"t":"2024-03-01T20:00:00Z","c":179.66}"#)
685                            .expect("bar should deserialize"),
686                    ],
687                ),
688                (
689                    "MSFT".into(),
690                    vec![
691                        serde_json::from_str(r#"{"t":"2024-03-01T20:00:00Z","c":415.32}"#)
692                            .expect("bar should deserialize"),
693                    ],
694                ),
695            ]),
696            next_page_token: Some("page-2".into()),
697            currency: Some("USD".into()),
698        };
699
700        first
701            .merge_page(BarsResponse {
702                bars: HashMap::from([
703                    (
704                        "AAPL".into(),
705                        vec![
706                            serde_json::from_str(r#"{"t":"2024-03-04T20:00:00Z","c":175.10}"#)
707                                .expect("bar should deserialize"),
708                        ],
709                    ),
710                    (
711                        "NVDA".into(),
712                        vec![
713                            serde_json::from_str(r#"{"t":"2024-03-04T20:00:00Z","c":852.37}"#)
714                                .expect("bar should deserialize"),
715                        ],
716                    ),
717                ]),
718                next_page_token: None,
719                currency: Some("USD".into()),
720            })
721            .expect("matching currencies should merge");
722
723        assert_eq!(first.bars.get("AAPL").map(Vec::len), Some(2));
724        assert_eq!(first.bars.get("MSFT").map(Vec::len), Some(1));
725        assert_eq!(first.bars.get("NVDA").map(Vec::len), Some(1));
726        assert_eq!(first.next_page_token, None);
727    }
728
729    #[test]
730    fn batch_historical_merge_rejects_mismatched_currency() {
731        let mut first = BarsResponse {
732            bars: HashMap::new(),
733            next_page_token: Some("page-2".into()),
734            currency: Some("USD".into()),
735        };
736
737        let error = first
738            .merge_page(BarsResponse {
739                bars: HashMap::new(),
740                next_page_token: None,
741                currency: Some("CAD".into()),
742            })
743            .expect_err("mismatched currencies should fail");
744
745        assert!(matches!(error, Error::Pagination(_)));
746    }
747}