1use std::collections::HashMap;
2
3use crate::{Error, transport::pagination::PaginatedResponse};
4
5use super::{Bar, Orderbook, 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 QuotesResponse {
15 pub quotes: HashMap<String, Vec<Quote>>,
16 pub next_page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
20pub struct TradesResponse {
21 pub trades: HashMap<String, Vec<Trade>>,
22 pub next_page_token: Option<String>,
23}
24
25#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
26pub struct LatestBarsResponse {
27 pub bars: HashMap<String, Bar>,
28}
29
30#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
31pub struct LatestQuotesResponse {
32 pub quotes: HashMap<String, Quote>,
33}
34
35#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
36pub struct LatestTradesResponse {
37 pub trades: HashMap<String, Trade>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
41pub struct LatestOrderbooksResponse {
42 pub orderbooks: HashMap<String, Orderbook>,
43}
44
45#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
46pub struct SnapshotsResponse {
47 pub snapshots: HashMap<String, Snapshot>,
48}
49
50fn merge_batch_page<Item>(
51 current: &mut HashMap<String, Vec<Item>>,
52 next: HashMap<String, Vec<Item>>,
53) {
54 for (symbol, mut items) in next {
55 current.entry(symbol).or_default().append(&mut items);
56 }
57}
58
59impl PaginatedResponse for BarsResponse {
60 fn next_page_token(&self) -> Option<&str> {
61 self.next_page_token.as_deref()
62 }
63
64 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
65 merge_batch_page(&mut self.bars, next.bars);
66 self.next_page_token = next.next_page_token;
67 Ok(())
68 }
69
70 fn clear_next_page_token(&mut self) {
71 self.next_page_token = None;
72 }
73}
74
75impl PaginatedResponse for QuotesResponse {
76 fn next_page_token(&self) -> Option<&str> {
77 self.next_page_token.as_deref()
78 }
79
80 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
81 merge_batch_page(&mut self.quotes, next.quotes);
82 self.next_page_token = next.next_page_token;
83 Ok(())
84 }
85
86 fn clear_next_page_token(&mut self) {
87 self.next_page_token = None;
88 }
89}
90
91impl PaginatedResponse for TradesResponse {
92 fn next_page_token(&self) -> Option<&str> {
93 self.next_page_token.as_deref()
94 }
95
96 fn merge_page(&mut self, next: Self) -> Result<(), Error> {
97 merge_batch_page(&mut self.trades, next.trades);
98 self.next_page_token = next.next_page_token;
99 Ok(())
100 }
101
102 fn clear_next_page_token(&mut self) {
103 self.next_page_token = None;
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use std::{collections::HashMap, str::FromStr};
110
111 use crate::transport::pagination::PaginatedResponse;
112 use rust_decimal::Decimal;
113
114 use super::{
115 Bar, BarsResponse, LatestBarsResponse, LatestOrderbooksResponse, LatestQuotesResponse,
116 LatestTradesResponse, QuotesResponse, SnapshotsResponse, TradesResponse,
117 };
118
119 #[test]
120 fn historical_responses_deserialize_official_wrapper_shapes() {
121 let bars: BarsResponse = serde_json::from_str(
122 r#"{"bars":{"BTC/USD":[{"c":66926.1935,"h":66946.85,"l":66920.6075,"n":0,"o":66942.46,"t":"2026-04-04T00:00:00Z","v":0.02821505,"vw":66933.72875}]},"next_page_token":"page-2"}"#,
123 )
124 .expect("bars response should deserialize");
125 assert_eq!(bars.next_page_token.as_deref(), Some("page-2"));
126 assert_eq!(
127 bars.bars.get("BTC/USD").map(Vec::len).unwrap_or_default(),
128 1
129 );
130
131 let quotes: QuotesResponse = serde_json::from_str(
132 r#"{"quotes":{"BTC/USD":[{"ap":67005.5,"as":1.26733,"bp":66894.8,"bs":2.56753,"t":"2026-04-04T00:00:04.184229364Z"}]},"next_page_token":"page-3"}"#,
133 )
134 .expect("quotes response should deserialize");
135 assert_eq!(quotes.next_page_token.as_deref(), Some("page-3"));
136 assert_eq!(
137 quotes
138 .quotes
139 .get("BTC/USD")
140 .map(Vec::len)
141 .unwrap_or_default(),
142 1
143 );
144 assert_eq!(
145 quotes
146 .quotes
147 .get("BTC/USD")
148 .and_then(|quotes| quotes.first())
149 .and_then(|quote| quote.ap.as_ref())
150 .cloned(),
151 Some(Decimal::from_str("67005.5").expect("decimal literal should parse"))
152 );
153 assert_eq!(
154 quotes
155 .quotes
156 .get("BTC/USD")
157 .and_then(|quotes| quotes.first())
158 .and_then(|quote| quote.r#as.as_ref())
159 .cloned(),
160 Some(Decimal::from_str("1.26733").expect("decimal literal should parse"))
161 );
162
163 let trades: TradesResponse = serde_json::from_str(
164 r#"{"trades":{"BTC/USD":[{"i":5536632919737126473,"p":66969.687,"s":0.000073,"t":"2026-04-04T00:01:02.445726428Z","tks":"B"}]},"next_page_token":"page-4"}"#,
165 )
166 .expect("trades response should deserialize");
167 assert_eq!(trades.next_page_token.as_deref(), Some("page-4"));
168 assert_eq!(
169 trades
170 .trades
171 .get("BTC/USD")
172 .map(Vec::len)
173 .unwrap_or_default(),
174 1
175 );
176 }
177
178 #[test]
179 fn historical_merge_combines_symbol_buckets_and_clears_next_page_token() {
180 let mut first = BarsResponse {
181 bars: HashMap::from([(
182 "BTC/USD".into(),
183 vec![Bar {
184 t: Some("2026-04-04T00:00:00Z".into()),
185 ..Bar::default()
186 }],
187 )]),
188 next_page_token: Some("page-2".into()),
189 };
190 let second = BarsResponse {
191 bars: HashMap::from([(
192 "ETH/USD".into(),
193 vec![Bar {
194 t: Some("2026-04-04T00:00:00Z".into()),
195 ..Bar::default()
196 }],
197 )]),
198 next_page_token: None,
199 };
200
201 first
202 .merge_page(second)
203 .expect("merge should combine pages without error");
204 first.clear_next_page_token();
205
206 assert_eq!(first.next_page_token, None);
207 assert_eq!(first.bars.len(), 2);
208 }
209
210 #[test]
211 fn quote_and_trade_merge_append_more_items() {
212 let mut quotes = QuotesResponse {
213 quotes: HashMap::from([(
214 "BTC/USD".into(),
215 vec![super::Quote {
216 t: Some("2026-04-04T00:00:04.184229364Z".into()),
217 ..super::Quote::default()
218 }],
219 )]),
220 next_page_token: Some("page-2".into()),
221 };
222 quotes
223 .merge_page(QuotesResponse {
224 quotes: HashMap::from([(
225 "BTC/USD".into(),
226 vec![super::Quote {
227 t: Some("2026-04-04T00:00:05.184229364Z".into()),
228 ..super::Quote::default()
229 }],
230 )]),
231 next_page_token: None,
232 })
233 .expect("quote merge should append items");
234 assert_eq!(
235 quotes
236 .quotes
237 .get("BTC/USD")
238 .map(Vec::len)
239 .unwrap_or_default(),
240 2
241 );
242
243 let mut trades = TradesResponse {
244 trades: HashMap::from([(
245 "BTC/USD".into(),
246 vec![super::Trade {
247 t: Some("2026-04-04T00:01:02.445726428Z".into()),
248 ..super::Trade::default()
249 }],
250 )]),
251 next_page_token: Some("page-3".into()),
252 };
253 trades
254 .merge_page(TradesResponse {
255 trades: HashMap::from([(
256 "BTC/USD".into(),
257 vec![super::Trade {
258 t: Some("2026-04-04T00:01:03.445726428Z".into()),
259 ..super::Trade::default()
260 }],
261 )]),
262 next_page_token: None,
263 })
264 .expect("trade merge should append items");
265 assert_eq!(
266 trades
267 .trades
268 .get("BTC/USD")
269 .map(Vec::len)
270 .unwrap_or_default(),
271 2
272 );
273 }
274
275 #[test]
276 fn latest_responses_deserialize_official_wrapper_shapes() {
277 let latest_bars: LatestBarsResponse = serde_json::from_str(
278 r#"{"bars":{"BTC/USD":{"c":66800.79,"h":66817.1675,"l":66800.79,"n":0,"o":66812.172,"t":"2026-04-04T04:13:00Z","v":0.0,"vw":66808.97875}}}"#,
279 )
280 .expect("latest bars response should deserialize");
281 assert!(latest_bars.bars.contains_key("BTC/USD"));
282 assert_eq!(
283 latest_bars
284 .bars
285 .get("BTC/USD")
286 .and_then(|bar| bar.c.as_ref())
287 .cloned(),
288 Some(Decimal::from_str("66800.79").expect("decimal literal should parse"))
289 );
290 assert_eq!(
291 latest_bars
292 .bars
293 .get("BTC/USD")
294 .and_then(|bar| bar.v.as_ref())
295 .map(ToString::to_string),
296 Some(
297 Decimal::from_str("0.0")
298 .expect("decimal literal should parse")
299 .to_string()
300 )
301 );
302 assert_eq!(
303 latest_bars
304 .bars
305 .get("BTC/USD")
306 .and_then(|bar| bar.v.as_ref())
307 .map(Decimal::scale),
308 Some(1)
309 );
310
311 let latest_quotes: LatestQuotesResponse = serde_json::from_str(
312 r#"{"quotes":{"BTC/USD":{"ap":66819.4,"as":1.28052,"bp":66763.431,"bs":1.272,"t":"2026-04-04T04:14:35.580241652Z"}}}"#,
313 )
314 .expect("latest quotes response should deserialize");
315 assert!(latest_quotes.quotes.contains_key("BTC/USD"));
316 assert_eq!(
317 latest_quotes
318 .quotes
319 .get("BTC/USD")
320 .and_then(|quote| quote.bp.as_ref())
321 .cloned(),
322 Some(Decimal::from_str("66763.431").expect("decimal literal should parse"))
323 );
324 assert_eq!(
325 latest_quotes
326 .quotes
327 .get("BTC/USD")
328 .and_then(|quote| quote.bs.as_ref())
329 .map(Decimal::scale),
330 Some(3)
331 );
332
333 let latest_trades: LatestTradesResponse = serde_json::from_str(
334 r#"{"trades":{"BTC/USD":{"i":519366231866950988,"p":66842.8,"s":0.000828,"t":"2026-04-04T04:12:55.361347989Z","tks":"B"}}}"#,
335 )
336 .expect("latest trades response should deserialize");
337 assert!(latest_trades.trades.contains_key("BTC/USD"));
338 assert_eq!(
339 latest_trades
340 .trades
341 .get("BTC/USD")
342 .and_then(|trade| trade.s.as_ref())
343 .cloned(),
344 Some(Decimal::from_str("0.000828").expect("decimal literal should parse"))
345 );
346
347 let latest_orderbooks: LatestOrderbooksResponse = serde_json::from_str(
348 r#"{"orderbooks":{"BTC/USD":{"a":[{"p":66819.4,"s":1.28052},{"p":66847.8,"s":2.5525}],"b":[{"p":66763.431,"s":1.272},{"p":66743.135,"s":2.5795}],"t":"2026-04-04T04:14:35.581059122Z"}}}"#,
349 )
350 .expect("latest orderbooks response should deserialize");
351 assert!(latest_orderbooks.orderbooks.contains_key("BTC/USD"));
352 assert_eq!(
353 latest_orderbooks
354 .orderbooks
355 .get("BTC/USD")
356 .and_then(|orderbook| orderbook.a.as_ref())
357 .and_then(|asks| asks.first())
358 .and_then(|level| level.s.as_ref())
359 .cloned(),
360 Some(Decimal::from_str("1.28052").expect("decimal literal should parse"))
361 );
362 }
363
364 #[test]
365 fn snapshots_response_deserializes_official_wrapper_shape() {
366 let snapshots: SnapshotsResponse = serde_json::from_str(
367 r#"{"snapshots":{"BTC/USD":{"dailyBar":{"c":66800.79,"h":66975.1,"l":66772.66,"n":87,"o":66942.46,"t":"2026-04-04T00:00:00Z","v":0.029938953,"vw":66854.9651939408},"latestQuote":{"ap":66819.4,"as":1.28052,"bp":66763.431,"bs":1.272,"t":"2026-04-04T04:14:35.580241652Z"},"latestTrade":{"i":7456836641300628344,"p":66832.6,"s":0.000946,"t":"2026-04-04T04:14:32.161121311Z","tks":"B"},"minuteBar":{"c":66800.79,"h":66817.1675,"l":66800.79,"n":0,"o":66812.172,"t":"2026-04-04T04:13:00Z","v":0.0,"vw":66808.97875},"prevDailyBar":{"c":66961.45,"h":67293.2523,"l":66252.479,"n":549,"o":66887.805,"t":"2026-04-03T00:00:00Z","v":1.117036142,"vw":66779.3688392417}}}}"#,
368 )
369 .expect("snapshots response should deserialize");
370
371 let snapshot = snapshots
372 .snapshots
373 .get("BTC/USD")
374 .expect("snapshot response should include the symbol");
375 assert!(snapshot.latestTrade.is_some());
376 assert!(snapshot.latestQuote.is_some());
377 assert!(snapshot.minuteBar.is_some());
378 assert!(snapshot.dailyBar.is_some());
379 assert!(snapshot.prevDailyBar.is_some());
380 }
381}