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}