alpaca_data/crypto/
request.rs

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}