Skip to main content

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(&params);
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}