1use crate::Error;
2use crate::common::query::QueryWriter;
3use crate::transport::pagination::PaginatedRequest;
4
5use super::{Loc, Sort, TimeFrame};
6
7#[derive(Clone, Debug, Default)]
8pub struct BarsRequest {
9 pub symbols: Vec<String>,
10 pub timeframe: TimeFrame,
11 pub start: Option<String>,
12 pub end: Option<String>,
13 pub limit: Option<u32>,
14 pub sort: Option<Sort>,
15 pub loc: Option<Loc>,
16 pub page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default)]
20pub struct QuotesRequest {
21 pub symbols: Vec<String>,
22 pub start: Option<String>,
23 pub end: Option<String>,
24 pub limit: Option<u32>,
25 pub sort: Option<Sort>,
26 pub loc: Option<Loc>,
27 pub page_token: Option<String>,
28}
29
30#[derive(Clone, Debug, Default)]
31pub struct TradesRequest {
32 pub symbols: Vec<String>,
33 pub start: Option<String>,
34 pub end: Option<String>,
35 pub limit: Option<u32>,
36 pub sort: Option<Sort>,
37 pub loc: Option<Loc>,
38 pub page_token: Option<String>,
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct LatestBarsRequest {
43 pub symbols: Vec<String>,
44 pub loc: Option<Loc>,
45}
46
47#[derive(Clone, Debug, Default)]
48pub struct LatestQuotesRequest {
49 pub symbols: Vec<String>,
50 pub loc: Option<Loc>,
51}
52
53#[derive(Clone, Debug, Default)]
54pub struct LatestTradesRequest {
55 pub symbols: Vec<String>,
56 pub loc: Option<Loc>,
57}
58
59#[derive(Clone, Debug, Default)]
60pub struct LatestOrderbooksRequest {
61 pub symbols: Vec<String>,
62 pub loc: Option<Loc>,
63}
64
65#[derive(Clone, Debug, Default)]
66pub struct SnapshotsRequest {
67 pub symbols: Vec<String>,
68 pub loc: Option<Loc>,
69}
70
71impl BarsRequest {
72 pub(crate) fn validate(&self) -> Result<(), Error> {
73 validate_required_symbols(&self.symbols)?;
74 validate_limit(self.limit, 1, 10_000)
75 }
76
77 pub(crate) fn to_query(self) -> Vec<(String, String)> {
78 let mut query = QueryWriter::default();
79 query.push_csv("symbols", self.symbols);
80 query.push_opt("timeframe", Some(self.timeframe));
81 query.push_opt("start", self.start);
82 query.push_opt("end", self.end);
83 query.push_opt("limit", self.limit);
84 query.push_opt("page_token", self.page_token);
85 query.push_opt("sort", self.sort);
86 query.finish()
87 }
88}
89
90impl QuotesRequest {
91 pub(crate) fn validate(&self) -> Result<(), Error> {
92 validate_required_symbols(&self.symbols)?;
93 validate_limit(self.limit, 1, 10_000)
94 }
95
96 pub(crate) fn to_query(self) -> Vec<(String, String)> {
97 let mut query = QueryWriter::default();
98 query.push_csv("symbols", self.symbols);
99 query.push_opt("start", self.start);
100 query.push_opt("end", self.end);
101 query.push_opt("limit", self.limit);
102 query.push_opt("page_token", self.page_token);
103 query.push_opt("sort", self.sort);
104 query.finish()
105 }
106}
107
108impl TradesRequest {
109 pub(crate) fn validate(&self) -> Result<(), Error> {
110 validate_required_symbols(&self.symbols)?;
111 validate_limit(self.limit, 1, 10_000)
112 }
113
114 pub(crate) fn to_query(self) -> Vec<(String, String)> {
115 let mut query = QueryWriter::default();
116 query.push_csv("symbols", self.symbols);
117 query.push_opt("start", self.start);
118 query.push_opt("end", self.end);
119 query.push_opt("limit", self.limit);
120 query.push_opt("page_token", self.page_token);
121 query.push_opt("sort", self.sort);
122 query.finish()
123 }
124}
125
126impl LatestBarsRequest {
127 pub(crate) fn validate(&self) -> Result<(), Error> {
128 validate_required_symbols(&self.symbols)
129 }
130
131 pub(crate) fn to_query(self) -> Vec<(String, String)> {
132 latest_query(self.symbols)
133 }
134}
135
136impl LatestQuotesRequest {
137 pub(crate) fn validate(&self) -> Result<(), Error> {
138 validate_required_symbols(&self.symbols)
139 }
140
141 pub(crate) fn to_query(self) -> Vec<(String, String)> {
142 latest_query(self.symbols)
143 }
144}
145
146impl LatestTradesRequest {
147 pub(crate) fn validate(&self) -> Result<(), Error> {
148 validate_required_symbols(&self.symbols)
149 }
150
151 pub(crate) fn to_query(self) -> Vec<(String, String)> {
152 latest_query(self.symbols)
153 }
154}
155
156impl LatestOrderbooksRequest {
157 pub(crate) fn validate(&self) -> Result<(), Error> {
158 validate_required_symbols(&self.symbols)
159 }
160
161 pub(crate) fn to_query(self) -> Vec<(String, String)> {
162 latest_query(self.symbols)
163 }
164}
165
166impl SnapshotsRequest {
167 pub(crate) fn validate(&self) -> Result<(), Error> {
168 validate_required_symbols(&self.symbols)
169 }
170
171 pub(crate) fn to_query(self) -> Vec<(String, String)> {
172 latest_query(self.symbols)
173 }
174}
175
176impl PaginatedRequest for BarsRequest {
177 fn with_page_token(&self, page_token: Option<String>) -> Self {
178 let mut next = self.clone();
179 next.page_token = page_token;
180 next
181 }
182}
183
184impl PaginatedRequest for QuotesRequest {
185 fn with_page_token(&self, page_token: Option<String>) -> Self {
186 let mut next = self.clone();
187 next.page_token = page_token;
188 next
189 }
190}
191
192impl PaginatedRequest for TradesRequest {
193 fn with_page_token(&self, page_token: Option<String>) -> Self {
194 let mut next = self.clone();
195 next.page_token = page_token;
196 next
197 }
198}
199
200fn latest_query(symbols: Vec<String>) -> Vec<(String, String)> {
201 let mut query = QueryWriter::default();
202 query.push_csv("symbols", symbols);
203 query.finish()
204}
205
206fn validate_required_symbols(symbols: &[String]) -> Result<(), Error> {
207 if symbols.is_empty() {
208 return Err(Error::InvalidRequest("symbols must not be empty".into()));
209 }
210
211 Ok(())
212}
213
214fn validate_limit(limit: Option<u32>, min: u32, max: u32) -> Result<(), Error> {
215 if let Some(limit) = limit {
216 if !(min..=max).contains(&limit) {
217 return Err(Error::InvalidRequest(format!(
218 "limit must be between {min} and {max}"
219 )));
220 }
221 }
222
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use crate::Error;
229 use crate::transport::pagination::PaginatedRequest;
230
231 use super::{
232 BarsRequest, LatestBarsRequest, LatestOrderbooksRequest, LatestQuotesRequest,
233 LatestTradesRequest, Loc, QuotesRequest, SnapshotsRequest, Sort, TimeFrame, TradesRequest,
234 };
235
236 #[test]
237 fn bars_request_serializes_official_query_words_without_loc() {
238 let query = BarsRequest {
239 symbols: vec!["BTC/USD".into(), "ETH/USD".into()],
240 timeframe: TimeFrame::from("1Min"),
241 start: Some("2026-04-04T00:00:00Z".into()),
242 end: Some("2026-04-04T00:02:00Z".into()),
243 limit: Some(2),
244 sort: Some(Sort::Desc),
245 loc: Some(Loc::Eu1),
246 page_token: Some("page-2".into()),
247 }
248 .to_query();
249
250 assert_eq!(
251 query,
252 vec![
253 ("symbols".to_string(), "BTC/USD,ETH/USD".to_string()),
254 ("timeframe".to_string(), "1Min".to_string()),
255 ("start".to_string(), "2026-04-04T00:00:00Z".to_string()),
256 ("end".to_string(), "2026-04-04T00:02:00Z".to_string()),
257 ("limit".to_string(), "2".to_string()),
258 ("page_token".to_string(), "page-2".to_string()),
259 ("sort".to_string(), "desc".to_string()),
260 ]
261 );
262 }
263
264 #[test]
265 fn quotes_and_trades_requests_serialize_official_query_words_without_loc() {
266 let quotes_query = QuotesRequest {
267 symbols: vec!["BTC/USD".into()],
268 start: Some("2026-04-04T00:00:00Z".into()),
269 end: Some("2026-04-04T00:00:05Z".into()),
270 limit: Some(1),
271 sort: Some(Sort::Asc),
272 loc: Some(Loc::Us1),
273 page_token: Some("page-3".into()),
274 }
275 .to_query();
276 assert_eq!(
277 quotes_query,
278 vec![
279 ("symbols".to_string(), "BTC/USD".to_string()),
280 ("start".to_string(), "2026-04-04T00:00:00Z".to_string()),
281 ("end".to_string(), "2026-04-04T00:00:05Z".to_string()),
282 ("limit".to_string(), "1".to_string()),
283 ("page_token".to_string(), "page-3".to_string()),
284 ("sort".to_string(), "asc".to_string()),
285 ]
286 );
287
288 let trades_query = TradesRequest {
289 symbols: vec!["BTC/USD".into()],
290 start: Some("2026-04-04T00:01:00Z".into()),
291 end: Some("2026-04-04T00:01:03Z".into()),
292 limit: Some(1),
293 sort: Some(Sort::Desc),
294 loc: Some(Loc::Us),
295 page_token: Some("page-4".into()),
296 }
297 .to_query();
298 assert_eq!(
299 trades_query,
300 vec![
301 ("symbols".to_string(), "BTC/USD".to_string()),
302 ("start".to_string(), "2026-04-04T00:01:00Z".to_string()),
303 ("end".to_string(), "2026-04-04T00:01:03Z".to_string()),
304 ("limit".to_string(), "1".to_string()),
305 ("page_token".to_string(), "page-4".to_string()),
306 ("sort".to_string(), "desc".to_string()),
307 ]
308 );
309 }
310
311 #[test]
312 fn historical_requests_replace_page_token_through_shared_pagination_trait() {
313 let bars = BarsRequest {
314 page_token: Some("page-2".into()),
315 ..BarsRequest::default()
316 };
317 let quotes = QuotesRequest {
318 page_token: Some("page-3".into()),
319 ..QuotesRequest::default()
320 };
321 let trades = TradesRequest {
322 page_token: Some("page-4".into()),
323 ..TradesRequest::default()
324 };
325
326 assert_eq!(
327 bars.with_page_token(Some("page-9".into()))
328 .page_token
329 .as_deref(),
330 Some("page-9")
331 );
332 assert_eq!(
333 quotes
334 .with_page_token(Some("page-8".into()))
335 .page_token
336 .as_deref(),
337 Some("page-8")
338 );
339 assert_eq!(
340 trades
341 .with_page_token(Some("page-7".into()))
342 .page_token
343 .as_deref(),
344 Some("page-7")
345 );
346 }
347
348 #[test]
349 fn latest_requests_serialize_symbols_only_without_loc() {
350 let bars_query = LatestBarsRequest {
351 symbols: vec!["BTC/USD".into(), "ETH/USD".into()],
352 loc: Some(Loc::Us1),
353 }
354 .to_query();
355 assert_eq!(
356 bars_query,
357 vec![("symbols".to_string(), "BTC/USD,ETH/USD".to_string())]
358 );
359
360 let quotes_query = LatestQuotesRequest {
361 symbols: vec!["BTC/USD".into()],
362 loc: Some(Loc::Eu1),
363 }
364 .to_query();
365 assert_eq!(
366 quotes_query,
367 vec![("symbols".to_string(), "BTC/USD".to_string())]
368 );
369
370 let trades_query = LatestTradesRequest {
371 symbols: vec!["BTC/USD".into()],
372 loc: Some(Loc::Us),
373 }
374 .to_query();
375 assert_eq!(
376 trades_query,
377 vec![("symbols".to_string(), "BTC/USD".to_string())]
378 );
379
380 let orderbooks_query = LatestOrderbooksRequest {
381 symbols: vec!["BTC/USD".into()],
382 loc: Some(Loc::Us1),
383 }
384 .to_query();
385 assert_eq!(
386 orderbooks_query,
387 vec![("symbols".to_string(), "BTC/USD".to_string())]
388 );
389 }
390
391 #[test]
392 fn snapshots_request_serializes_symbols_only_without_loc() {
393 let query = SnapshotsRequest {
394 symbols: vec!["BTC/USD".into(), "ETH/USD".into()],
395 loc: Some(Loc::Eu1),
396 }
397 .to_query();
398
399 assert_eq!(
400 query,
401 vec![("symbols".to_string(), "BTC/USD,ETH/USD".to_string())]
402 );
403 }
404
405 #[test]
406 fn requests_reject_empty_symbols_for_required_symbol_endpoints() {
407 let errors = [
408 BarsRequest::default()
409 .validate()
410 .expect_err("bars symbols must be required"),
411 QuotesRequest::default()
412 .validate()
413 .expect_err("quotes symbols must be required"),
414 TradesRequest::default()
415 .validate()
416 .expect_err("trades symbols must be required"),
417 LatestBarsRequest::default()
418 .validate()
419 .expect_err("latest bars symbols must be required"),
420 LatestQuotesRequest::default()
421 .validate()
422 .expect_err("latest quotes symbols must be required"),
423 LatestTradesRequest::default()
424 .validate()
425 .expect_err("latest trades symbols must be required"),
426 LatestOrderbooksRequest::default()
427 .validate()
428 .expect_err("latest orderbooks symbols must be required"),
429 SnapshotsRequest::default()
430 .validate()
431 .expect_err("snapshots symbols must be required"),
432 ];
433
434 for error in errors {
435 assert!(matches!(
436 error,
437 Error::InvalidRequest(message)
438 if message.contains("symbols") && message.contains("empty")
439 ));
440 }
441 }
442
443 #[test]
444 fn historical_requests_reject_limits_outside_documented_range() {
445 let errors = [
446 BarsRequest {
447 symbols: vec!["BTC/USD".into()],
448 limit: Some(0),
449 ..BarsRequest::default()
450 }
451 .validate()
452 .expect_err("bars limit below one must fail"),
453 QuotesRequest {
454 symbols: vec!["BTC/USD".into()],
455 limit: Some(10_001),
456 ..QuotesRequest::default()
457 }
458 .validate()
459 .expect_err("quotes limit above ten thousand must fail"),
460 TradesRequest {
461 symbols: vec!["BTC/USD".into()],
462 limit: Some(0),
463 ..TradesRequest::default()
464 }
465 .validate()
466 .expect_err("trades limit below one must fail"),
467 ];
468
469 for error in errors {
470 assert!(matches!(
471 error,
472 Error::InvalidRequest(message)
473 if message.contains("limit") && message.contains("10000")
474 ));
475 }
476 }
477}