Skip to main content

alpaca_data/options/
request.rs

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