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, str::FromStr};
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    use rust_decimal::Decimal;
356
357    #[test]
358    fn single_historical_responses_deserialize_official_wrapper_fields() {
359        let auctions: AuctionsSingleResponse = serde_json::from_str(
360            r#"{"symbol":"AAPL","auctions":[],"next_page_token":"page-1","currency":"USD"}"#,
361        )
362        .expect("auctions single response should deserialize");
363        assert_eq!(auctions.symbol, "AAPL");
364        assert_eq!(auctions.next_page_token.as_deref(), Some("page-1"));
365        assert_eq!(
366            auctions.currency.as_ref().map(|value| value.as_str()),
367            Some("USD")
368        );
369
370        let bars: BarsSingleResponse = serde_json::from_str(
371            r#"{"symbol":"AAPL","bars":[],"next_page_token":"page-2","currency":"USD"}"#,
372        )
373        .expect("bars single response should deserialize");
374        assert_eq!(bars.symbol, "AAPL");
375        assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
376        assert_eq!(
377            bars.currency.as_ref().map(|value| value.as_str()),
378            Some("USD")
379        );
380
381        let quotes: QuotesSingleResponse = serde_json::from_str(
382            r#"{"symbol":"AAPL","quotes":[],"next_page_token":"page-3","currency":"USD"}"#,
383        )
384        .expect("quotes single response should deserialize");
385        assert_eq!(quotes.symbol, "AAPL");
386        assert_eq!(quotes.next_page_token.as_deref(), Some("page-3"));
387        assert_eq!(
388            quotes.currency.as_ref().map(|value| value.as_str()),
389            Some("USD")
390        );
391
392        let trades: TradesSingleResponse = serde_json::from_str(
393            r#"{"symbol":"AAPL","trades":[],"next_page_token":"page-4","currency":"USD"}"#,
394        )
395        .expect("trades single response should deserialize");
396        assert_eq!(trades.symbol, "AAPL");
397        assert_eq!(trades.next_page_token.as_deref(), Some("page-4"));
398        assert_eq!(
399            trades.currency.as_ref().map(|value| value.as_str()),
400            Some("USD")
401        );
402    }
403
404    #[test]
405    fn single_historical_merge_preserves_symbol_and_currency() {
406        let mut auctions = AuctionsSingleResponse {
407            symbol: "AAPL".into(),
408            auctions: vec![],
409            next_page_token: Some("page-2".into()),
410            currency: Some("USD".into()),
411        };
412
413        auctions
414            .merge_page(AuctionsSingleResponse {
415                symbol: "AAPL".into(),
416                auctions: vec![],
417                next_page_token: None,
418                currency: Some("USD".into()),
419            })
420            .expect("matching auction pages should merge");
421
422        assert_eq!(auctions.symbol, "AAPL");
423        assert_eq!(
424            auctions.currency.as_ref().map(|value| value.as_str()),
425            Some("USD")
426        );
427        assert_eq!(auctions.next_page_token, None);
428
429        let mut first = BarsSingleResponse {
430            symbol: "AAPL".into(),
431            bars: vec![],
432            next_page_token: Some("page-2".into()),
433            currency: Some("USD".into()),
434        };
435
436        first
437            .merge_page(BarsSingleResponse {
438                symbol: "AAPL".into(),
439                bars: vec![],
440                next_page_token: None,
441                currency: Some("USD".into()),
442            })
443            .expect("matching pages should merge");
444
445        assert_eq!(first.symbol, "AAPL");
446        assert_eq!(
447            first.currency.as_ref().map(|value| value.as_str()),
448            Some("USD")
449        );
450        assert_eq!(first.next_page_token, None);
451    }
452
453    #[test]
454    fn single_historical_merge_rejects_mismatched_symbol_or_currency() {
455        let mut auctions_symbol_mismatch = AuctionsSingleResponse {
456            symbol: "AAPL".into(),
457            auctions: vec![],
458            next_page_token: Some("page-2".into()),
459            currency: Some("USD".into()),
460        };
461
462        let auctions_symbol_error = auctions_symbol_mismatch
463            .merge_page(AuctionsSingleResponse {
464                symbol: "MSFT".into(),
465                auctions: vec![],
466                next_page_token: None,
467                currency: Some("USD".into()),
468            })
469            .expect_err("mismatched auction symbols should fail");
470        assert!(matches!(auctions_symbol_error, Error::Pagination(_)));
471
472        let mut symbol_mismatch = BarsSingleResponse {
473            symbol: "AAPL".into(),
474            bars: vec![],
475            next_page_token: Some("page-2".into()),
476            currency: Some("USD".into()),
477        };
478
479        let symbol_error = symbol_mismatch
480            .merge_page(BarsSingleResponse {
481                symbol: "MSFT".into(),
482                bars: vec![],
483                next_page_token: None,
484                currency: Some("USD".into()),
485            })
486            .expect_err("mismatched symbols should fail");
487        assert!(matches!(symbol_error, Error::Pagination(_)));
488
489        let mut currency_mismatch = BarsSingleResponse {
490            symbol: "AAPL".into(),
491            bars: vec![],
492            next_page_token: Some("page-2".into()),
493            currency: Some("USD".into()),
494        };
495
496        let currency_error = currency_mismatch
497            .merge_page(BarsSingleResponse {
498                symbol: "AAPL".into(),
499                bars: vec![],
500                next_page_token: None,
501                currency: Some("CAD".into()),
502            })
503            .expect_err("mismatched currencies should fail");
504        assert!(matches!(currency_error, Error::Pagination(_)));
505    }
506
507    #[test]
508    fn latest_responses_deserialize_official_wrapper_shapes() {
509        let batch_bars: LatestBarsResponse = serde_json::from_str(
510            r#"{"bars":{"AAPL":{"t":"2024-03-01T20:00:00Z","c":179.66}},"currency":"USD"}"#,
511        )
512        .expect("latest bars response should deserialize");
513        assert!(batch_bars.bars.contains_key("AAPL"));
514        assert_eq!(
515            batch_bars
516                .bars
517                .get("AAPL")
518                .and_then(|bar| bar.c.as_ref())
519                .cloned(),
520            Some(Decimal::from_str("179.66").expect("decimal literal should parse"))
521        );
522        assert_eq!(
523            batch_bars.currency.as_ref().map(|value| value.as_str()),
524            Some("USD")
525        );
526
527        let single_bar: LatestBarResponse = serde_json::from_str(
528            r#"{"symbol":"AAPL","bar":{"t":"2024-03-01T20:00:00Z","c":179.66},"currency":"USD"}"#,
529        )
530        .expect("latest bar response should deserialize");
531        assert_eq!(single_bar.symbol, "AAPL");
532        assert_eq!(
533            single_bar.bar.c,
534            Some(Decimal::from_str("179.66").expect("decimal literal should parse"))
535        );
536
537        let batch_quotes: LatestQuotesResponse = serde_json::from_str(
538            r#"{"quotes":{"AAPL":{"t":"2024-03-01T20:00:00Z","bp":179.65}},"currency":"USD"}"#,
539        )
540        .expect("latest quotes response should deserialize");
541        assert!(batch_quotes.quotes.contains_key("AAPL"));
542        assert_eq!(
543            batch_quotes
544                .quotes
545                .get("AAPL")
546                .and_then(|quote| quote.bp.as_ref())
547                .cloned(),
548            Some(Decimal::from_str("179.65").expect("decimal literal should parse"))
549        );
550
551        let single_quote: LatestQuoteResponse = serde_json::from_str(
552            r#"{"symbol":"AAPL","quote":{"t":"2024-03-01T20:00:00Z","bp":179.65},"currency":"USD"}"#,
553        )
554        .expect("latest quote response should deserialize");
555        assert_eq!(single_quote.symbol, "AAPL");
556        assert_eq!(
557            single_quote.quote.bp,
558            Some(Decimal::from_str("179.65").expect("decimal literal should parse"))
559        );
560
561        let batch_trades: LatestTradesResponse = serde_json::from_str(
562            r#"{"trades":{"AAPL":{"t":"2024-03-01T20:00:00Z","p":179.64}},"currency":"USD"}"#,
563        )
564        .expect("latest trades response should deserialize");
565        assert!(batch_trades.trades.contains_key("AAPL"));
566        assert_eq!(
567            batch_trades
568                .trades
569                .get("AAPL")
570                .and_then(|trade| trade.p.as_ref())
571                .cloned(),
572            Some(Decimal::from_str("179.64").expect("decimal literal should parse"))
573        );
574
575        let single_trade: LatestTradeResponse = serde_json::from_str(
576            r#"{"symbol":"AAPL","trade":{"t":"2024-03-01T20:00:00Z","p":179.64},"currency":"USD"}"#,
577        )
578        .expect("latest trade response should deserialize");
579        assert_eq!(single_trade.symbol, "AAPL");
580        assert_eq!(
581            single_trade.trade.p,
582            Some(Decimal::from_str("179.64").expect("decimal literal should parse"))
583        );
584    }
585
586    #[test]
587    fn snapshot_responses_deserialize_official_batch_and_single_shapes() {
588        let batch: SnapshotsResponse = serde_json::from_str(
589            r#"{
590                "AAPL":{
591                    "latestTrade":{"t":"2024-03-01T20:00:00Z","p":179.64},
592                    "latestQuote":{"t":"2024-03-01T20:00:00Z","bp":179.65},
593                    "minuteBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
594                    "dailyBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
595                    "prevDailyBar":{"t":"2024-02-29T20:00:00Z","c":180.75}
596                }
597            }"#,
598        )
599        .expect("batch snapshots response should deserialize");
600        let aapl = batch
601            .get("AAPL")
602            .expect("batch snapshots response should keep the symbol as the top-level key");
603        assert!(aapl.latestTrade.is_some());
604        assert!(aapl.latestQuote.is_some());
605        assert!(aapl.minuteBar.is_some());
606        assert!(aapl.dailyBar.is_some());
607        assert!(aapl.prevDailyBar.is_some());
608        assert_eq!(
609            aapl.latestQuote
610                .as_ref()
611                .and_then(|quote| quote.bp.as_ref())
612                .cloned(),
613            Some(Decimal::from_str("179.65").expect("decimal literal should parse"))
614        );
615        assert_eq!(
616            aapl.latestTrade
617                .as_ref()
618                .and_then(|trade| trade.p.as_ref())
619                .cloned(),
620            Some(Decimal::from_str("179.64").expect("decimal literal should parse"))
621        );
622        assert_eq!(
623            aapl.minuteBar
624                .as_ref()
625                .and_then(|bar| bar.c.as_ref())
626                .cloned(),
627            Some(Decimal::from_str("179.66").expect("decimal literal should parse"))
628        );
629
630        let single: SnapshotResponse = serde_json::from_str(
631            r#"{
632                "symbol":"AAPL",
633                "currency":"USD",
634                "latestTrade":{"t":"2024-03-01T20:00:00Z","p":179.64},
635                "latestQuote":{"t":"2024-03-01T20:00:00Z","bp":179.65},
636                "minuteBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
637                "dailyBar":{"t":"2024-03-01T20:00:00Z","c":179.66},
638                "prevDailyBar":{"t":"2024-02-29T20:00:00Z","c":180.75}
639            }"#,
640        )
641        .expect("single snapshot response should deserialize");
642        assert_eq!(single.symbol, "AAPL");
643        assert_eq!(
644            single.currency.as_ref().map(|value| value.as_str()),
645            Some("USD")
646        );
647        assert!(single.latestTrade.is_some());
648        assert!(single.latestQuote.is_some());
649        assert!(single.minuteBar.is_some());
650        assert!(single.dailyBar.is_some());
651        assert!(single.prevDailyBar.is_some());
652        assert_eq!(
653            single
654                .latestQuote
655                .as_ref()
656                .and_then(|quote| quote.bp.as_ref())
657                .cloned(),
658            Some(Decimal::from_str("179.65").expect("decimal literal should parse"))
659        );
660    }
661
662    #[test]
663    fn metadata_responses_deserialize_official_map_shapes() {
664        let condition_codes: ConditionCodesResponse =
665            serde_json::from_str(r#"{" ":"Regular Sale","4":"Derivatively Priced"}"#)
666                .expect("condition codes should deserialize as a top-level map");
667        assert_eq!(
668            condition_codes.get(" ").map(String::as_str),
669            Some("Regular Sale")
670        );
671        assert_eq!(
672            condition_codes.get("4").map(String::as_str),
673            Some("Derivatively Priced")
674        );
675
676        let exchange_codes: ExchangeCodesResponse =
677            serde_json::from_str(r#"{"V":"IEX","N":"New York Stock Exchange"}"#)
678                .expect("exchange codes should deserialize as a top-level map");
679        assert_eq!(exchange_codes.get("V").map(String::as_str), Some("IEX"));
680        assert_eq!(
681            exchange_codes.get("N").map(String::as_str),
682            Some("New York Stock Exchange")
683        );
684    }
685
686    #[test]
687    fn auctions_responses_deserialize_official_wrapper_shapes() {
688        let batch: AuctionsResponse = serde_json::from_str(
689            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"}"#,
690        )
691        .expect("batch auctions response should deserialize");
692        assert_eq!(batch.next_page_token.as_deref(), Some("page-2"));
693        assert_eq!(
694            batch.currency.as_ref().map(|value| value.as_str()),
695            Some("USD")
696        );
697        assert_eq!(batch.auctions.get("AAPL").map(Vec::len), Some(1));
698        assert_eq!(
699            batch
700                .auctions
701                .get("AAPL")
702                .and_then(|days| days.first())
703                .and_then(|day| day.o.first())
704                .and_then(|auction| auction.p.as_ref())
705                .cloned(),
706            Some(Decimal::from_str("179.55").expect("decimal literal should parse"))
707        );
708
709        let single: AuctionsSingleResponse = serde_json::from_str(
710            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"}"#,
711        )
712        .expect("single auctions response should deserialize");
713        assert_eq!(single.symbol, "AAPL");
714        assert_eq!(single.auctions.len(), 1);
715        assert_eq!(
716            single
717                .auctions
718                .first()
719                .and_then(|day| day.c.first())
720                .and_then(|auction| auction.p.as_ref())
721                .cloned(),
722            Some(Decimal::from_str("179.64").expect("decimal literal should parse"))
723        );
724    }
725
726    #[test]
727    fn auctions_batch_merge_combines_symbol_buckets_and_clears_next_page_token() {
728        let mut first = AuctionsResponse {
729            auctions: HashMap::from([(
730                "AAPL".into(),
731                vec![serde_json::from_str(
732                    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"}]}"#,
733                )
734                .expect("daily auction should deserialize")],
735            )]),
736            next_page_token: Some("page-2".into()),
737            currency: Some("USD".into()),
738        };
739
740        first
741            .merge_page(AuctionsResponse {
742                auctions: HashMap::from([(
743                    "MSFT".into(),
744                    vec![serde_json::from_str(
745                        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"}]}"#,
746                    )
747                    .expect("daily auction should deserialize")],
748                )]),
749                next_page_token: None,
750                currency: Some("USD".into()),
751            })
752            .expect("matching auction currencies should merge");
753
754        assert_eq!(first.auctions.get("AAPL").map(Vec::len), Some(1));
755        assert_eq!(first.auctions.get("MSFT").map(Vec::len), Some(1));
756        assert_eq!(
757            first
758                .auctions
759                .get("MSFT")
760                .and_then(|days| days.first())
761                .and_then(|day| day.o.first())
762                .and_then(|auction| auction.p.as_ref())
763                .map(ToString::to_string),
764            Some(
765                Decimal::from_str("415.10")
766                    .expect("decimal literal should parse")
767                    .to_string()
768            )
769        );
770        assert_eq!(first.next_page_token, None);
771    }
772
773    #[test]
774    fn batch_historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
775        let mut first = BarsResponse {
776            bars: HashMap::from([
777                (
778                    "AAPL".into(),
779                    vec![
780                        serde_json::from_str(r#"{"t":"2024-03-01T20:00:00Z","c":179.66}"#)
781                            .expect("bar should deserialize"),
782                    ],
783                ),
784                (
785                    "MSFT".into(),
786                    vec![
787                        serde_json::from_str(r#"{"t":"2024-03-01T20:00:00Z","c":415.32}"#)
788                            .expect("bar should deserialize"),
789                    ],
790                ),
791            ]),
792            next_page_token: Some("page-2".into()),
793            currency: Some("USD".into()),
794        };
795
796        first
797            .merge_page(BarsResponse {
798                bars: HashMap::from([
799                    (
800                        "AAPL".into(),
801                        vec![
802                            serde_json::from_str(r#"{"t":"2024-03-04T20:00:00Z","c":175.10}"#)
803                                .expect("bar should deserialize"),
804                        ],
805                    ),
806                    (
807                        "NVDA".into(),
808                        vec![
809                            serde_json::from_str(r#"{"t":"2024-03-04T20:00:00Z","c":852.37}"#)
810                                .expect("bar should deserialize"),
811                        ],
812                    ),
813                ]),
814                next_page_token: None,
815                currency: Some("USD".into()),
816            })
817            .expect("matching currencies should merge");
818
819        assert_eq!(first.bars.get("AAPL").map(Vec::len), Some(2));
820        assert_eq!(first.bars.get("MSFT").map(Vec::len), Some(1));
821        assert_eq!(first.bars.get("NVDA").map(Vec::len), Some(1));
822        assert_eq!(
823            first
824                .bars
825                .get("AAPL")
826                .and_then(|bars| bars.last())
827                .and_then(|bar| bar.c.as_ref())
828                .map(ToString::to_string),
829            Some(
830                Decimal::from_str("175.10")
831                    .expect("decimal literal should parse")
832                    .to_string()
833            )
834        );
835        assert_eq!(first.next_page_token, None);
836    }
837
838    #[test]
839    fn batch_historical_merge_rejects_mismatched_currency() {
840        let mut first = BarsResponse {
841            bars: HashMap::new(),
842            next_page_token: Some("page-2".into()),
843            currency: Some("USD".into()),
844        };
845
846        let error = first
847            .merge_page(BarsResponse {
848                bars: HashMap::new(),
849                next_page_token: None,
850                currency: Some("CAD".into()),
851            })
852            .expect_err("mismatched currencies should fail");
853
854        assert!(matches!(error, Error::Pagination(_)));
855    }
856}