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