alpaca_data/corporate_actions/
request.rs

1use std::fmt::{self, Display, Formatter};
2
3use crate::Error;
4use crate::common::enums::Sort;
5use crate::common::query::QueryWriter;
6use crate::transport::pagination::PaginatedRequest;
7
8#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
9pub enum CorporateActionType {
10    #[default]
11    ForwardSplit,
12    ReverseSplit,
13    UnitSplit,
14    StockDividend,
15    CashDividend,
16    SpinOff,
17    CashMerger,
18    StockMerger,
19    StockAndCashMerger,
20    Redemption,
21    NameChange,
22    WorthlessRemoval,
23    RightsDistribution,
24    ContractAdjustment,
25    PartialCall,
26}
27
28impl Display for CorporateActionType {
29    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
30        formatter.write_str(match self {
31            Self::ForwardSplit => "forward_split",
32            Self::ReverseSplit => "reverse_split",
33            Self::UnitSplit => "unit_split",
34            Self::StockDividend => "stock_dividend",
35            Self::CashDividend => "cash_dividend",
36            Self::SpinOff => "spin_off",
37            Self::CashMerger => "cash_merger",
38            Self::StockMerger => "stock_merger",
39            Self::StockAndCashMerger => "stock_and_cash_merger",
40            Self::Redemption => "redemption",
41            Self::NameChange => "name_change",
42            Self::WorthlessRemoval => "worthless_removal",
43            Self::RightsDistribution => "rights_distribution",
44            Self::ContractAdjustment => "contract_adjustment",
45            Self::PartialCall => "partial_call",
46        })
47    }
48}
49
50#[derive(Clone, Debug, Default)]
51pub struct ListRequest {
52    pub symbols: Option<Vec<String>>,
53    pub cusips: Option<Vec<String>>,
54    pub types: Option<Vec<CorporateActionType>>,
55    pub start: Option<String>,
56    pub end: Option<String>,
57    pub ids: Option<Vec<String>>,
58    pub limit: Option<u32>,
59    pub sort: Option<Sort>,
60    pub page_token: Option<String>,
61}
62
63impl ListRequest {
64    pub(crate) fn validate(&self) -> Result<(), Error> {
65        validate_limit(self.limit, 1, 1_000)?;
66
67        if self.ids.is_some()
68            && (self.symbols.is_some()
69                || self.cusips.is_some()
70                || self.types.is_some()
71                || self.start.is_some()
72                || self.end.is_some())
73        {
74            return Err(Error::InvalidRequest(
75                "ids cannot be combined with other corporate actions filters".into(),
76            ));
77        }
78
79        Ok(())
80    }
81
82    pub(crate) fn to_query(self) -> Vec<(String, String)> {
83        let mut query = QueryWriter::default();
84        if let Some(symbols) = self.symbols {
85            query.push_csv("symbols", symbols);
86        }
87        if let Some(cusips) = self.cusips {
88            query.push_csv("cusips", cusips);
89        }
90        if let Some(types) = self.types {
91            query.push_csv("types", types.into_iter().map(|value| value.to_string()));
92        }
93        query.push_opt("start", self.start);
94        query.push_opt("end", self.end);
95        if let Some(ids) = self.ids {
96            query.push_csv("ids", ids);
97        }
98        query.push_opt("limit", self.limit);
99        query.push_opt("sort", self.sort);
100        query.push_opt("page_token", self.page_token);
101        query.finish()
102    }
103}
104
105fn validate_limit(limit: Option<u32>, min: u32, max: u32) -> Result<(), Error> {
106    if let Some(limit) = limit {
107        if !(min..=max).contains(&limit) {
108            return Err(Error::InvalidRequest(format!(
109                "limit must be between {min} and {max}"
110            )));
111        }
112    }
113
114    Ok(())
115}
116
117impl PaginatedRequest for ListRequest {
118    fn with_page_token(&self, page_token: Option<String>) -> Self {
119        let mut next = self.clone();
120        next.page_token = page_token;
121        next
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::{CorporateActionType, ListRequest};
128    use crate::{Error, common::enums::Sort};
129
130    #[test]
131    fn corporate_action_type_serializes_to_official_query_words() {
132        assert_eq!(
133            CorporateActionType::ForwardSplit.to_string(),
134            "forward_split"
135        );
136        assert_eq!(
137            CorporateActionType::ReverseSplit.to_string(),
138            "reverse_split"
139        );
140        assert_eq!(CorporateActionType::UnitSplit.to_string(), "unit_split");
141        assert_eq!(
142            CorporateActionType::StockDividend.to_string(),
143            "stock_dividend"
144        );
145        assert_eq!(
146            CorporateActionType::CashDividend.to_string(),
147            "cash_dividend"
148        );
149        assert_eq!(CorporateActionType::SpinOff.to_string(), "spin_off");
150        assert_eq!(CorporateActionType::CashMerger.to_string(), "cash_merger");
151        assert_eq!(CorporateActionType::StockMerger.to_string(), "stock_merger");
152        assert_eq!(
153            CorporateActionType::StockAndCashMerger.to_string(),
154            "stock_and_cash_merger"
155        );
156        assert_eq!(CorporateActionType::Redemption.to_string(), "redemption");
157        assert_eq!(CorporateActionType::NameChange.to_string(), "name_change");
158        assert_eq!(
159            CorporateActionType::WorthlessRemoval.to_string(),
160            "worthless_removal"
161        );
162        assert_eq!(
163            CorporateActionType::RightsDistribution.to_string(),
164            "rights_distribution"
165        );
166        assert_eq!(
167            CorporateActionType::ContractAdjustment.to_string(),
168            "contract_adjustment"
169        );
170        assert_eq!(CorporateActionType::PartialCall.to_string(), "partial_call");
171    }
172
173    #[test]
174    fn list_request_serializes_official_query_words() {
175        let query = ListRequest {
176            symbols: Some(vec!["AAPL".into(), "TSLA".into()]),
177            cusips: Some(vec!["037833100".into(), "88160R101".into()]),
178            types: Some(vec![
179                CorporateActionType::CashDividend,
180                CorporateActionType::NameChange,
181            ]),
182            start: Some("2024-08-01".into()),
183            end: Some("2024-08-20".into()),
184            ids: Some(vec!["ca-1".into(), "ca-2".into()]),
185            limit: Some(2),
186            sort: Some(Sort::Desc),
187            page_token: Some("page-2".into()),
188        }
189        .to_query();
190
191        assert_eq!(
192            query,
193            vec![
194                ("symbols".to_string(), "AAPL,TSLA".to_string()),
195                ("cusips".to_string(), "037833100,88160R101".to_string()),
196                ("types".to_string(), "cash_dividend,name_change".to_string(),),
197                ("start".to_string(), "2024-08-01".to_string()),
198                ("end".to_string(), "2024-08-20".to_string()),
199                ("ids".to_string(), "ca-1,ca-2".to_string()),
200                ("limit".to_string(), "2".to_string()),
201                ("sort".to_string(), "desc".to_string()),
202                ("page_token".to_string(), "page-2".to_string()),
203            ]
204        );
205    }
206
207    #[test]
208    fn list_request_rejects_limits_outside_documented_range() {
209        let low = ListRequest {
210            limit: Some(0),
211            ..ListRequest::default()
212        }
213        .validate()
214        .expect_err("limit below one must fail");
215        assert!(matches!(
216            low,
217            Error::InvalidRequest(message)
218                if message.contains("limit") && message.contains("1000")
219        ));
220
221        let high = ListRequest {
222            limit: Some(1001),
223            ..ListRequest::default()
224        }
225        .validate()
226        .expect_err("limit above one thousand must fail");
227        assert!(matches!(
228            high,
229            Error::InvalidRequest(message)
230                if message.contains("limit") && message.contains("1000")
231        ));
232    }
233
234    #[test]
235    fn ids_remain_mutually_exclusive_with_other_filters() {
236        let error = ListRequest {
237            symbols: Some(vec!["AAPL".into()]),
238            ids: Some(vec!["ca-1".into()]),
239            ..ListRequest::default()
240        }
241        .validate()
242        .expect_err("ids plus symbols must fail");
243        assert!(matches!(
244            error,
245            Error::InvalidRequest(message)
246                if message.contains("ids") && message.contains("filters")
247        ));
248
249        let error = ListRequest {
250            cusips: Some(vec!["037833100".into()]),
251            ids: Some(vec!["ca-1".into()]),
252            ..ListRequest::default()
253        }
254        .validate()
255        .expect_err("ids plus cusips must fail");
256        assert!(matches!(
257            error,
258            Error::InvalidRequest(message)
259                if message.contains("ids") && message.contains("filters")
260        ));
261    }
262}