Skip to main content

alpaca_data/options/
request.rs

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