rustrade_data/exchange/alpaca/reference.rs
1//! Alpaca corporate-actions reference data (`GET /v1beta1/corporate-actions`).
2//!
3//! The raw REST surface behind the [`StockSplitSource`] adapter: a [`CorporateActionsQuery`]
4//! builder, the nested response types, and the paginated
5//! [`AlpacaRestClient::fetch_splits_raw`] stream. Only stock-split actions are requested
6//! (`types=forward_split,reverse_split`); each row is normalised into an [`AlpacaStockSplit`].
7//!
8//! # Date semantics
9//!
10//! [`AlpacaStockSplit::ex_date`] is the split's **ex-date** — the session on which the share count
11//! re-bases and the price adjusts (observed equal to `process_date` across every sampled split).
12//! It is the field the [`StockSplitSource`] adapter maps onto the descriptor's `effective_date`.
13//! Alpaca also returns `payable_date`, which can fall a trading day *before* `ex_date` for forward
14//! splits and therefore must NOT be used as the effective date: an effective date earlier than the
15//! true ex-date would re-base a position before the market actually did.
16//!
17//! [`StockSplitSource`]: rustrade_integration::corporate_action::StockSplitSource
18
19use super::pagination::PaginationGuard;
20use super::rest::{AlpacaRestClient, AlpacaRestError};
21use async_stream::try_stream;
22use chrono::NaiveDate;
23use futures::Stream;
24use rust_decimal::Decimal;
25use serde::Deserialize;
26use smol_str::SmolStr;
27use tracing::debug;
28
29/// Maximum results per page (Alpaca API limit).
30const MAX_LIMIT: u16 = 1000;
31
32/// Maximum pages a single fetch will follow before failing with a terminal
33/// `PaginationLimitExceeded` (a runaway backstop, not a business limit).
34const MAX_PAGES: usize = 1000;
35
36/// The `types` filter value — this surface only fetches stock splits, never other action kinds.
37const SPLIT_TYPES: &str = "forward_split,reverse_split";
38
39// ============================================================================
40// Query Builder
41// ============================================================================
42
43/// Query parameters for the `GET /v1beta1/corporate-actions` splits endpoint.
44///
45/// Construct with [`CorporateActionsQuery::new`] and chain setters. The `types` filter is fixed to
46/// `forward_split,reverse_split`; pagination via `page_token` is handled automatically by
47/// [`AlpacaRestClient::fetch_splits_raw`].
48///
49/// # Example
50///
51/// ```
52/// use rustrade_data::exchange::alpaca::CorporateActionsQuery;
53/// use chrono::NaiveDate;
54///
55/// let query = CorporateActionsQuery::new()
56/// .symbols(["NVDA", "AAPL"])
57/// .start(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
58/// .end(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
59/// ```
60#[derive(Debug, Default, Clone)]
61pub struct CorporateActionsQuery {
62 /// Underlying symbols to filter by; empty means no symbol restriction (all symbols in range).
63 pub symbols: Vec<SmolStr>,
64 /// Inclusive lower bound on the action's effective date.
65 pub start: Option<NaiveDate>,
66 /// Inclusive upper bound on the action's effective date.
67 pub end: Option<NaiveDate>,
68 /// Results per page (clamped to 1000, the API maximum).
69 pub limit: Option<u16>,
70}
71
72impl CorporateActionsQuery {
73 /// Create a new empty query (all symbols, no date bounds, default pagination).
74 #[must_use]
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 /// Filter by underlying symbols (joined into Alpaca's comma-separated `symbols` parameter).
80 #[must_use]
81 pub fn symbols<I, S>(mut self, symbols: I) -> Self
82 where
83 I: IntoIterator<Item = S>,
84 S: Into<SmolStr>,
85 {
86 self.symbols = symbols.into_iter().map(Into::into).collect();
87 self
88 }
89
90 /// Filter to actions effective on or after `date`.
91 #[must_use]
92 pub fn start(mut self, date: NaiveDate) -> Self {
93 self.start = Some(date);
94 self
95 }
96
97 /// Filter to actions effective on or before `date`.
98 #[must_use]
99 pub fn end(mut self, date: NaiveDate) -> Self {
100 self.end = Some(date);
101 self
102 }
103
104 /// Set results per page (clamped to the API maximum of 1000).
105 #[must_use]
106 pub fn limit(mut self, limit: u16) -> Self {
107 self.limit = Some(limit.min(MAX_LIMIT));
108 self
109 }
110
111 /// Build the query-string parameters (excluding `page_token`, which the stream adds per page).
112 fn to_query_params(&self) -> Vec<(&'static str, String)> {
113 let mut params = vec![("types", SPLIT_TYPES.to_string())];
114
115 if !self.symbols.is_empty() {
116 params.push(("symbols", self.symbols.join(",")));
117 }
118 if let Some(start) = self.start {
119 params.push(("start", start.format("%Y-%m-%d").to_string()));
120 }
121 if let Some(end) = self.end {
122 params.push(("end", end.format("%Y-%m-%d").to_string()));
123 }
124 let limit = self.limit.unwrap_or(MAX_LIMIT).min(MAX_LIMIT);
125 params.push(("limit", limit.to_string()));
126
127 params
128 }
129}
130
131// ============================================================================
132// Response Types
133// ============================================================================
134
135/// A normalised Alpaca stock split (forward or reverse).
136///
137/// The direction is encoded by the ratio: a forward split has `new_rate > old_rate`, a reverse
138/// split has `new_rate < old_rate`. These map directly onto the provider-agnostic
139/// `CorporateActionKind::stock_split(split_to, split_from)` helper (`new_rate` = `split_to`,
140/// `old_rate` = `split_from`).
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct AlpacaStockSplit {
143 /// Underlying symbol (e.g. `"NVDA"`).
144 pub symbol: String,
145 /// Ex-date — the market execution date on which the split takes effect. See the module docs.
146 pub ex_date: NaiveDate,
147 /// Shares *after* the split (the ratio numerator; `split_to`).
148 pub new_rate: Decimal,
149 /// Shares *before* the split (the ratio denominator; `split_from`).
150 pub old_rate: Decimal,
151}
152
153/// Top-level response from the corporate-actions endpoint.
154#[derive(Debug, Default, Deserialize)]
155struct CorporateActionsResponse {
156 #[serde(default)]
157 corporate_actions: CorporateActions,
158 #[serde(default)]
159 next_page_token: Option<String>,
160}
161
162/// The `corporate_actions` object: split rows partitioned into forward and reverse arrays.
163#[derive(Debug, Default, Deserialize)]
164struct CorporateActions {
165 #[serde(default)]
166 forward_splits: Vec<RawSplit>,
167 #[serde(default)]
168 reverse_splits: Vec<RawSplit>,
169}
170
171impl CorporateActions {
172 /// Flatten both arrays into normalised splits, forward rows first.
173 fn into_stock_splits(self) -> impl Iterator<Item = AlpacaStockSplit> {
174 self.forward_splits
175 .into_iter()
176 .chain(self.reverse_splits)
177 .map(RawSplit::into_stock_split)
178 }
179}
180
181/// A raw split row. Forward and reverse rows share the fields we consume (`symbol`, `new_rate`,
182/// `old_rate`, `ex_date`); they differ only in their CUSIP shape (`cusip` vs `new_cusip`/
183/// `old_cusip`) and other dates (`payable_date`, `process_date`, …), none of which a split source
184/// needs — those are simply ignored, so a single struct deserialises both arrays.
185///
186/// `new_rate` / `old_rate` arrive as JSON numbers (e.g. `10`, `1`), so they use the float decimal
187/// representation — identical to the Massive split client.
188#[derive(Debug, Deserialize)]
189struct RawSplit {
190 symbol: String,
191 #[serde(with = "rust_decimal::serde::float")]
192 new_rate: Decimal,
193 #[serde(with = "rust_decimal::serde::float")]
194 old_rate: Decimal,
195 ex_date: NaiveDate,
196}
197
198impl RawSplit {
199 fn into_stock_split(self) -> AlpacaStockSplit {
200 AlpacaStockSplit {
201 symbol: self.symbol,
202 ex_date: self.ex_date,
203 new_rate: self.new_rate,
204 old_rate: self.old_rate,
205 }
206 }
207}
208
209// ============================================================================
210// Client
211// ============================================================================
212
213impl AlpacaRestClient {
214 /// Fetch stock splits matching `query` from `GET /v1beta1/corporate-actions`.
215 ///
216 /// Returns a stream that handles `page_token` pagination automatically, yielding each
217 /// forward/reverse split as a normalised [`AlpacaStockSplit`]. Pagination is bounded: the
218 /// stream yields a terminal [`AlpacaRestError::PaginationLimitExceeded`] if it exceeds
219 /// 1000 pages, and [`AlpacaRestError::CyclicPagination`] if the server repeats a
220 /// `page_token` — a silently truncated result would be indistinguishable from a genuinely
221 /// small one, so incomplete pagination fails loudly instead.
222 ///
223 /// # Example
224 ///
225 /// ```ignore
226 /// use rustrade_data::exchange::alpaca::{AlpacaRestClient, CorporateActionsQuery};
227 /// use futures::StreamExt;
228 ///
229 /// let client = AlpacaRestClient::from_env()?;
230 /// let query = CorporateActionsQuery::new().symbols(["NVDA"]);
231 /// let mut stream = client.fetch_splits_raw(&query);
232 /// while let Some(split) = stream.next().await {
233 /// println!("{:?}", split?);
234 /// }
235 /// ```
236 pub fn fetch_splits_raw<'a>(
237 &'a self,
238 query: &'a CorporateActionsQuery,
239 ) -> impl Stream<Item = Result<AlpacaStockSplit, AlpacaRestError>> + 'a {
240 try_stream! {
241 let url = format!("{}/v1beta1/corporate-actions", self.data_base());
242 let mut page_token: Option<String> = None;
243 let mut guard = PaginationGuard::new(MAX_PAGES);
244
245 loop {
246 guard.observe(page_token.as_deref())?;
247
248 let mut params = query.to_query_params();
249 if let Some(ref token) = page_token {
250 params.push(("page_token", token.clone()));
251 }
252
253 debug!(url = %url, page = guard.pages(), "Fetching Alpaca corporate-actions page");
254 let request = self.get(&url).query(¶ms);
255 let response: CorporateActionsResponse = self.request_with_retry(request).await?;
256
257 let CorporateActionsResponse { corporate_actions, next_page_token } = response;
258 for split in corporate_actions.into_stock_splits() {
259 yield split;
260 }
261
262 match next_page_token {
263 Some(token) if !token.is_empty() => page_token = Some(token),
264 _ => break,
265 }
266 }
267 }
268 }
269}
270
271// ============================================================================
272// Tests
273// ============================================================================
274
275#[cfg(test)]
276#[allow(clippy::unwrap_used)] // Tests should panic on unexpected values
277mod tests {
278 use super::*;
279
280 #[test]
281 fn query_empty_sets_only_types_and_limit() {
282 let params = CorporateActionsQuery::new().to_query_params();
283 assert!(params.contains(&("types", SPLIT_TYPES.to_string())));
284 assert!(params.iter().any(|(k, v)| *k == "limit" && v == "1000"));
285 assert!(!params.iter().any(|(k, _)| *k == "symbols"));
286 assert!(!params.iter().any(|(k, _)| *k == "start" || *k == "end"));
287 }
288
289 #[test]
290 fn query_joins_symbols_and_dates() {
291 let params = CorporateActionsQuery::new()
292 .symbols(["NVDA", "AAPL"])
293 .start(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
294 .end(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap())
295 .to_query_params();
296
297 assert!(
298 params
299 .iter()
300 .any(|(k, v)| *k == "symbols" && v == "NVDA,AAPL")
301 );
302 assert!(
303 params
304 .iter()
305 .any(|(k, v)| *k == "start" && v == "2024-01-01")
306 );
307 assert!(params.iter().any(|(k, v)| *k == "end" && v == "2024-12-31"));
308 }
309
310 #[test]
311 fn query_limit_clamped_to_max() {
312 let params = CorporateActionsQuery::new().limit(5000).to_query_params();
313 assert!(params.iter().any(|(k, v)| *k == "limit" && v == "1000"));
314 }
315
316 #[test]
317 fn documented_limits_match_their_constants() {
318 // Pinned against literals because the public rustdoc states both bounds in prose ("clamped
319 // to 1000", "1000 pages") — it cannot link these private constants under
320 // `deny(rustdoc::private_intra_doc_links)`. Without this, changing a constant would leave
321 // the documented contract silently wrong. `MAX_LIMIT` is also covered indirectly by the
322 // wire-param assertions above; `MAX_PAGES` has no other pin.
323 assert_eq!(MAX_LIMIT, 1000);
324 assert_eq!(MAX_PAGES, 1000);
325 }
326
327 #[test]
328 fn parse_forward_split_response() {
329 // NVDA 10-for-1 forward split (ex == process == payable here).
330 let json = r#"{
331 "corporate_actions": {
332 "forward_splits": [{
333 "symbol": "NVDA", "cusip": "67066G104",
334 "new_rate": 10, "old_rate": 1,
335 "ex_date": "2024-06-10", "process_date": "2024-06-10",
336 "payable_date": "2024-06-10", "record_date": "2024-06-07",
337 "due_bill_redemption_date": "2024-06-10",
338 "id": "50199fac-0af8-43ef-9846-eaf64c6d322d"
339 }]
340 },
341 "next_page_token": null
342 }"#;
343
344 let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
345 assert!(parsed.next_page_token.is_none());
346
347 let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
348 assert_eq!(splits.len(), 1);
349 assert_eq!(
350 splits[0],
351 AlpacaStockSplit {
352 symbol: "NVDA".to_string(),
353 ex_date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
354 new_rate: Decimal::from(10),
355 old_rate: Decimal::from(1),
356 }
357 );
358 }
359
360 #[test]
361 fn parse_reverse_split_response_with_dual_cusip() {
362 // ATRA 1-for-25 reverse split — note the `new_cusip`/`old_cusip` shape (ignored).
363 let json = r#"{
364 "corporate_actions": {
365 "reverse_splits": [{
366 "symbol": "ATRA", "new_rate": 1, "old_rate": 25,
367 "new_cusip": "046513206", "old_cusip": "046513107",
368 "ex_date": "2024-06-20", "process_date": "2024-06-20",
369 "payable_date": "2024-06-20", "record_date": "2024-06-20",
370 "id": "446f18f3-92fc-42a8-8b93-4700f06bc8e0"
371 }]
372 }
373 }"#;
374
375 let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
376 let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
377 assert_eq!(splits.len(), 1);
378 assert_eq!(splits[0].symbol, "ATRA");
379 assert_eq!(splits[0].new_rate, Decimal::from(1));
380 assert_eq!(splits[0].old_rate, Decimal::from(25));
381 assert_eq!(
382 splits[0].ex_date,
383 NaiveDate::from_ymd_opt(2024, 6, 20).unwrap()
384 );
385 }
386
387 #[test]
388 fn parse_both_arrays_forward_first_with_page_token() {
389 let json = r#"{
390 "corporate_actions": {
391 "forward_splits": [{
392 "symbol": "NVDA", "new_rate": 10, "old_rate": 1, "ex_date": "2024-06-10"
393 }],
394 "reverse_splits": [{
395 "symbol": "ATRA", "new_rate": 1, "old_rate": 25, "ex_date": "2024-06-20"
396 }]
397 },
398 "next_page_token": "next123"
399 }"#;
400
401 let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
402 assert_eq!(parsed.next_page_token.as_deref(), Some("next123"));
403
404 let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
405 // Forward rows come first.
406 assert_eq!(splits[0].symbol, "NVDA");
407 assert_eq!(splits[1].symbol, "ATRA");
408 }
409
410 #[test]
411 fn parse_empty_response() {
412 let json = r#"{ "corporate_actions": {}, "next_page_token": null }"#;
413 let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
414 assert_eq!(parsed.corporate_actions.into_stock_splits().count(), 0);
415 }
416
417 #[test]
418 fn ex_date_provenance_ignores_payable_date() {
419 // APH forward split where `payable_date` (2024-06-11) precedes `ex_date` (2024-06-12) by a
420 // trading day. Pins that the parsed `ex_date` is the JSON `ex_date` — NOT `payable_date`
421 // (which would apply the split a day early; see `corporate_action`'s impl rustdoc).
422 let json = r#"{
423 "corporate_actions": {
424 "forward_splits": [{
425 "symbol": "APH", "new_rate": 2, "old_rate": 1,
426 "ex_date": "2024-06-12", "process_date": "2024-06-12",
427 "payable_date": "2024-06-11", "record_date": "2024-05-31"
428 }]
429 }
430 }"#;
431
432 let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
433 let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
434 assert_eq!(splits.len(), 1);
435 assert_eq!(
436 splits[0].ex_date,
437 NaiveDate::from_ymd_opt(2024, 6, 12).unwrap(),
438 "ex_date must be the JSON ex_date, never payable_date (2024-06-11)"
439 );
440 }
441}