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}