1use std::collections::HashMap;
2
3use crate::{Error, transport::pagination::PaginatedResponse};
4
5use super::{Bar, Quote, Snapshot, Trade};
6
7#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
8pub struct BarsResponse {
9 pub bars: HashMap<String, Vec<Bar>>,
10 pub next_page_token: Option<String>,
11}
12
13#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
14pub struct TradesResponse {
15 pub trades: HashMap<String, Vec<Trade>>,
16 pub next_page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
20pub struct LatestQuotesResponse {
21 pub quotes: HashMap<String, Quote>,
22}
23
24#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
25pub struct LatestTradesResponse {
26 pub trades: HashMap<String, Trade>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
30pub struct SnapshotsResponse {
31 pub snapshots: HashMap<String, Snapshot>,
32 pub next_page_token: Option<String>,
33}
34
35#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
36pub struct ChainResponse {
37 pub snapshots: HashMap<String, Snapshot>,
38 pub next_page_token: Option<String>,
39}
40
41pub type ConditionCodesResponse = HashMap<String, String>;
42
43pub type ExchangeCodesResponse = HashMap<String, String>;
44
45fn merge_batch_page<Item>(
46 current: &mut HashMap<String, Vec<Item>>,
47 next: HashMap<String, Vec<Item>>,
48) {
49 for (symbol, mut items) in next {
50 current.entry(symbol).or_default().append(&mut items);
51 }
52}
53
54fn merge_snapshot_page(
55 operation: &'static str,
56 current: &mut HashMap<String, Snapshot>,
57 next: HashMap<String, Snapshot>,
58) -> Result<(), Error> {
59 for (symbol, snapshot) in next {
60 if current.insert(symbol.clone(), snapshot).is_some() {
61 return Err(Error::Pagination(format!(
62 "{operation} received duplicate symbol across pages: {symbol}"
63 )));
64 }
65 }
66
67 Ok(())
68}
69
70impl PaginatedResponse for BarsResponse {
71 fn next_page_token(&self) -> Option<&str> {
72 self.next_page_token.as_deref()
73 }
74
75 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
76 merge_batch_page(&mut self.bars, next.bars);
77 self.next_page_token = next.next_page_token;
78 Ok(())
79 }
80
81 fn clear_next_page_token(&mut self) {
82 self.next_page_token = None;
83 }
84}
85
86impl PaginatedResponse for TradesResponse {
87 fn next_page_token(&self) -> Option<&str> {
88 self.next_page_token.as_deref()
89 }
90
91 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
92 merge_batch_page(&mut self.trades, next.trades);
93 self.next_page_token = next.next_page_token;
94 Ok(())
95 }
96
97 fn clear_next_page_token(&mut self) {
98 self.next_page_token = None;
99 }
100}
101
102impl PaginatedResponse for SnapshotsResponse {
103 fn next_page_token(&self) -> Option<&str> {
104 self.next_page_token.as_deref()
105 }
106
107 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
108 merge_snapshot_page("options.snapshots", &mut self.snapshots, next.snapshots)?;
109 self.next_page_token = next.next_page_token;
110 Ok(())
111 }
112
113 fn clear_next_page_token(&mut self) {
114 self.next_page_token = None;
115 }
116}
117
118impl PaginatedResponse for ChainResponse {
119 fn next_page_token(&self) -> Option<&str> {
120 self.next_page_token.as_deref()
121 }
122
123 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
124 merge_snapshot_page("options.chain", &mut self.snapshots, next.snapshots)?;
125 self.next_page_token = next.next_page_token;
126 Ok(())
127 }
128
129 fn clear_next_page_token(&mut self) {
130 self.next_page_token = None;
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use std::{collections::HashMap, str::FromStr};
137
138 use super::{
139 Bar, BarsResponse, ChainResponse, ConditionCodesResponse, ExchangeCodesResponse,
140 LatestQuotesResponse, LatestTradesResponse, SnapshotsResponse, Trade, TradesResponse,
141 };
142 use crate::{Error, transport::pagination::PaginatedResponse};
143 use rust_decimal::Decimal;
144
145 #[test]
146 fn historical_responses_deserialize_official_wrapper_shapes() {
147 let bars: BarsResponse = serde_json::from_str(
148 r#"{"bars":{"AAPL260406C00180000":[{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T04:00:00Z","v":2,"vw":71.09}]},"next_page_token":"page-2"}"#,
149 )
150 .expect("bars response should deserialize");
151 assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
152 assert_eq!(
153 bars.bars
154 .get("AAPL260406C00180000")
155 .map(Vec::len)
156 .unwrap_or_default(),
157 1
158 );
159 assert_eq!(
160 bars.bars
161 .get("AAPL260406C00180000")
162 .and_then(|bars| bars.first())
163 .and_then(|bar| bar.o.as_ref())
164 .map(ToString::to_string),
165 Some(Decimal::from_str("71.20").expect("decimal literal should parse"))
166 .map(|value| value.to_string())
167 );
168
169 let trades: TradesResponse = serde_json::from_str(
170 r#"{"trades":{"AAPL260406C00180000":[{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"}]},"next_page_token":"page-3"}"#,
171 )
172 .expect("trades response should deserialize");
173 assert_eq!(trades.next_page_token.as_deref(), Some("page-3"));
174 assert_eq!(
175 trades
176 .trades
177 .get("AAPL260406C00180000")
178 .map(Vec::len)
179 .unwrap_or_default(),
180 1
181 );
182 assert_eq!(
183 trades
184 .trades
185 .get("AAPL260406C00180000")
186 .and_then(|trades| trades.first())
187 .and_then(|trade| trade.p.as_ref())
188 .map(ToString::to_string),
189 Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
190 .map(|value| value.to_string())
191 );
192 }
193
194 #[test]
195 fn historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
196 let mut first = BarsResponse {
197 bars: HashMap::from([(
198 "AAPL260406C00180000".into(),
199 vec![Bar {
200 t: Some("2026-04-02T04:00:00Z".into()),
201 ..Bar::default()
202 }],
203 )]),
204 next_page_token: Some("page-2".into()),
205 };
206 let second = BarsResponse {
207 bars: HashMap::from([(
208 "AAPL260406C00185000".into(),
209 vec![Bar {
210 t: Some("2026-04-02T04:00:00Z".into()),
211 ..Bar::default()
212 }],
213 )]),
214 next_page_token: None,
215 };
216
217 first
218 .merge_page(second)
219 .expect("merge should combine pages without error");
220 first.clear_next_page_token();
221
222 assert_eq!(first.next_page_token, None);
223 assert_eq!(first.bars.len(), 2);
224 }
225
226 #[test]
227 fn historical_merge_accepts_multiple_trade_pages() {
228 let mut first = TradesResponse {
229 trades: HashMap::from([(
230 "AAPL260406C00180000".into(),
231 vec![Trade {
232 t: Some("2026-04-02T13:39:17.488838508Z".into()),
233 ..Trade::default()
234 }],
235 )]),
236 next_page_token: Some("page-2".into()),
237 };
238 let second = TradesResponse {
239 trades: HashMap::from([(
240 "AAPL260406C00180000".into(),
241 vec![Trade {
242 t: Some("2026-04-02T13:39:38.883488197Z".into()),
243 ..Trade::default()
244 }],
245 )]),
246 next_page_token: None,
247 };
248
249 first
250 .merge_page(second)
251 .expect("trade merge should append more items");
252
253 assert_eq!(
254 first
255 .trades
256 .get("AAPL260406C00180000")
257 .map(Vec::len)
258 .unwrap_or_default(),
259 2
260 );
261 }
262
263 #[test]
264 fn latest_responses_deserialize_official_wrapper_shapes() {
265 let quotes: LatestQuotesResponse = serde_json::from_str(
266 r#"{"quotes":{"AAPL260406C00180000":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}}"#,
267 )
268 .expect("latest quotes response should deserialize");
269 assert!(quotes.quotes.contains_key("AAPL260406C00180000"));
270 assert_eq!(
271 quotes
272 .quotes
273 .get("AAPL260406C00180000")
274 .and_then(|quote| quote.bp.as_ref())
275 .cloned(),
276 Some(Decimal::from_str("73.95").expect("decimal literal should parse"))
277 );
278
279 let trades: LatestTradesResponse = serde_json::from_str(
280 r#"{"trades":{"AAPL260406C00180000":{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"}}}"#,
281 )
282 .expect("latest trades response should deserialize");
283 assert!(trades.trades.contains_key("AAPL260406C00180000"));
284 assert_eq!(
285 trades
286 .trades
287 .get("AAPL260406C00180000")
288 .and_then(|trade| trade.p.as_ref())
289 .map(ToString::to_string),
290 Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
291 .map(|value| value.to_string())
292 );
293 }
294
295 #[test]
296 fn condition_codes_response_deserializes_official_map_shape() {
297 let condition_codes: ConditionCodesResponse = serde_json::from_str(
298 r#"{"a":"SLAN - Single Leg Auction Non ISO","e":"SLFT - Single Leg Floor Trade"}"#,
299 )
300 .expect("condition codes response should deserialize");
301 assert_eq!(
302 condition_codes.get("a").map(String::as_str),
303 Some("SLAN - Single Leg Auction Non ISO")
304 );
305 assert_eq!(
306 condition_codes.get("e").map(String::as_str),
307 Some("SLFT - Single Leg Floor Trade")
308 );
309 }
310
311 #[test]
312 fn exchange_codes_response_deserializes_official_map_shape() {
313 let exchange_codes: ExchangeCodesResponse = serde_json::from_str(
314 r#"{"A":"AMEX - NYSE American","O":"OPRA - Options Price Reporting Authority"}"#,
315 )
316 .expect("exchange codes response should deserialize");
317 assert_eq!(
318 exchange_codes.get("A").map(String::as_str),
319 Some("AMEX - NYSE American")
320 );
321 assert_eq!(
322 exchange_codes.get("O").map(String::as_str),
323 Some("OPRA - Options Price Reporting Authority")
324 );
325 }
326
327 #[test]
328 fn snapshot_responses_deserialize_official_wrapper_shapes() {
329 let snapshots: SnapshotsResponse = serde_json::from_str(
330 r#"{"snapshots":{"AAPL260406C00180000":{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"},"latestTrade":{"c":"n","p":70.90,"s":1,"t":"2026-04-02T13:39:38.883488197Z","x":"I"},"minuteBar":{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T13:39:00Z","v":2,"vw":71.09},"dailyBar":{"c":70.97,"h":71.21,"l":70.97,"n":2,"o":71.20,"t":"2026-04-02T04:00:00Z","v":2,"vw":71.09},"prevDailyBar":{"c":72.32,"h":72.32,"l":72.32,"n":1,"o":72.32,"t":"2026-04-01T04:00:00Z","v":1,"vw":72.32},"greeks":{"delta":0.0232,"gamma":0.0118,"rho":0.0005,"theta":-0.043,"vega":0.0127},"impliedVolatility":0.2006}},"next_page_token":"page-2"}"#,
331 )
332 .expect("snapshots response should deserialize");
333 assert_eq!(snapshots.next_page_token.as_deref(), Some("page-2"));
334 let snapshot = snapshots
335 .snapshots
336 .get("AAPL260406C00180000")
337 .expect("snapshots response should include the symbol");
338 assert!(snapshot.latestQuote.is_some());
339 assert!(snapshot.latestTrade.is_some());
340 assert!(snapshot.greeks.is_some());
341 assert_eq!(
342 snapshot.impliedVolatility,
343 Some(Decimal::from_str("0.2006").expect("decimal literal should parse"))
344 );
345 assert_eq!(
346 snapshot
347 .greeks
348 .as_ref()
349 .and_then(|greeks| greeks.theta.as_ref())
350 .cloned(),
351 Some(Decimal::from_str("-0.043").expect("decimal literal should parse"))
352 );
353 assert_eq!(
354 snapshot
355 .latestQuote
356 .as_ref()
357 .and_then(|quote| quote.bp.as_ref())
358 .cloned(),
359 Some(Decimal::from_str("73.95").expect("decimal literal should parse"))
360 );
361 assert_eq!(
362 snapshot
363 .latestTrade
364 .as_ref()
365 .and_then(|trade| trade.p.as_ref())
366 .map(ToString::to_string),
367 Some(Decimal::from_str("70.90").expect("decimal literal should parse"))
368 .map(|value| value.to_string())
369 );
370 assert_eq!(
371 snapshot
372 .minuteBar
373 .as_ref()
374 .and_then(|bar| bar.o.as_ref())
375 .map(ToString::to_string),
376 Some(Decimal::from_str("71.20").expect("decimal literal should parse"))
377 .map(|value| value.to_string())
378 );
379
380 let chain: ChainResponse = serde_json::from_str(
381 r#"{"snapshots":{"AAPL260406C00185000":{"latestQuote":{"ap":72.85,"as":5,"ax":"X","bp":69.1,"bs":4,"bx":"S","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}},"next_page_token":null}"#,
382 )
383 .expect("chain response should deserialize");
384 assert!(chain.snapshots.contains_key("AAPL260406C00185000"));
385 assert_eq!(chain.next_page_token, None);
386 }
387
388 #[test]
389 fn snapshot_merge_combines_distinct_symbol_pages_and_clears_next_page_token() {
390 let mut first = SnapshotsResponse {
391 snapshots: HashMap::from([(
392 "AAPL260406C00180000".into(),
393 serde_json::from_str(
394 r#"{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
395 )
396 .expect("first snapshot should deserialize"),
397 )]),
398 next_page_token: Some("page-2".into()),
399 };
400 let second = SnapshotsResponse {
401 snapshots: HashMap::from([(
402 "AAPL260406C00185000".into(),
403 serde_json::from_str(
404 r#"{"latestQuote":{"ap":72.85,"as":5,"ax":"X","bp":69.1,"bs":4,"bx":"S","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
405 )
406 .expect("second snapshot should deserialize"),
407 )]),
408 next_page_token: None,
409 };
410
411 first
412 .merge_page(second)
413 .expect("snapshots merge should combine distinct symbols");
414 first.clear_next_page_token();
415
416 assert_eq!(first.next_page_token, None);
417 assert_eq!(first.snapshots.len(), 2);
418 }
419
420 #[test]
421 fn snapshot_merge_rejects_duplicate_symbols_across_pages() {
422 let mut first = ChainResponse {
423 snapshots: HashMap::from([(
424 "AAPL260406C00180000".into(),
425 serde_json::from_str(
426 r#"{"latestQuote":{"ap":77.75,"as":5,"ax":"A","bp":73.95,"bs":3,"bx":"N","c":" ","t":"2026-04-02T19:59:59.792862244Z"}}"#,
427 )
428 .expect("first snapshot should deserialize"),
429 )]),
430 next_page_token: Some("page-2".into()),
431 };
432 let second = ChainResponse {
433 snapshots: HashMap::from([(
434 "AAPL260406C00180000".into(),
435 serde_json::from_str(
436 r#"{"latestQuote":{"ap":78.00,"as":1,"ax":"B","bp":74.00,"bs":2,"bx":"A","c":" ","t":"2026-04-02T20:00:00Z"}}"#,
437 )
438 .expect("duplicate snapshot should deserialize"),
439 )]),
440 next_page_token: None,
441 };
442
443 let error = first
444 .merge_page(second)
445 .expect_err("duplicate symbols should be rejected");
446 assert!(matches!(error, Error::Pagination(_)));
447 }
448}