Skip to main content

rustrade_data/exchange/alpaca/
corporate_action.rs

1//! [`StockSplitSource`] implementation for the shared Alpaca REST client.
2//!
3//! Adapts the lower-level [`AlpacaRestClient::fetch_splits_raw`](super::AlpacaRestClient::fetch_splits_raw) raw-split stream into
4//! the provider-agnostic [`CorporateAction`] sourcing descriptor, computing the split ratio via the
5//! shared [`CorporateActionKind::stock_split`] helper so Alpaca derives ratios identically to every
6//! other provider.
7
8use async_stream::try_stream;
9use futures::{Stream, StreamExt};
10use smol_str::SmolStr;
11use tracing::warn;
12
13use rustrade_instrument::corporate_action::{CorporateAction, CorporateActionKind};
14use rustrade_integration::corporate_action::{CorporateActionFilter, StockSplitSource};
15
16use super::reference::{AlpacaStockSplit, CorporateActionsQuery};
17use super::rest::{AlpacaRestClient, AlpacaRestError};
18
19impl StockSplitSource for AlpacaRestClient {
20    type Error = AlpacaRestError;
21
22    /// Fetch stock splits for `filter`'s symbols and effective-date range, mapped to
23    /// [`CorporateAction<SmolStr>`] descriptors.
24    ///
25    /// `effective_date` provenance: this impl maps it onto Alpaca's **`ex_date`** — the session on
26    /// which the share count re-bases and the price adjusts, satisfying the
27    /// [`StockSplitSource::fetch_splits`] market-execution-date contract. It deliberately does NOT
28    /// use `payable_date`: for forward splits Alpaca's `payable_date` can fall a trading day
29    /// *before* `ex_date` (sampled: `APH` ex 2024-06-12 / payable 2024-06-11; `CNQ` ex 2024-06-11 /
30    /// payable 2024-06-10) while the price re-bases on `ex_date` (observed equal to `process_date`
31    /// across the sample), so mapping `payable_date` would apply the split a day early — look-ahead
32    /// corruption. This mapping is pinned by `ex_date_provenance_ignores_payable_date` (in
33    /// [`reference`](super::reference)) and `map_split_maps_ex_date_to_effective_date` below.
34    ///
35    /// Alpaca's endpoint accepts a single comma-joined `symbols` parameter, so — unlike Massive's
36    /// per-symbol fan-out — a multi-symbol filter issues **one** paginated query. An empty `symbols`
37    /// list requests every split in the date range (subject to provider page limits). Splits whose
38    /// `new_rate` / `old_rate` yield a degenerate ratio (zero, negative, or division by zero) are
39    /// logged and skipped — see [`CorporateActionKind::stock_split`].
40    fn fetch_splits(
41        &self,
42        filter: &CorporateActionFilter,
43    ) -> impl Stream<Item = Result<CorporateAction<SmolStr>, Self::Error>> + Send {
44        // Build the owned query eagerly so the returned stream borrows only `self`, not `filter`.
45        let query = build_query(filter);
46
47        try_stream! {
48            // Calls the *inherent* `AlpacaRestClient::fetch_splits_raw` (taking `&CorporateActionsQuery`),
49            // the lower-level raw-split stream this impl adapts. Its distinct name keeps the dispatch
50            // explicit — no collision with this trait's `fetch_splits`, so no risk of recursing into it.
51            let raw = self.fetch_splits_raw(&query);
52            futures::pin_mut!(raw);
53            while let Some(split) = raw.next().await {
54                if let Some(action) = map_split(split?) {
55                    yield action;
56                }
57            }
58        }
59    }
60}
61
62/// Translate a [`CorporateActionFilter`] into a single Alpaca [`CorporateActionsQuery`] (Alpaca
63/// batches symbols into one comma-joined request).
64fn build_query(filter: &CorporateActionFilter) -> CorporateActionsQuery {
65    let mut query = CorporateActionsQuery::new();
66    if !filter.symbols.is_empty() {
67        query = query.symbols(filter.symbols.iter().map(SmolStr::as_str));
68    }
69    if let Some(start) = filter.start {
70        query = query.start(start);
71    }
72    if let Some(end) = filter.end {
73        query = query.end(end);
74    }
75    query
76}
77
78/// Map a raw [`AlpacaStockSplit`] to a [`CorporateAction`] descriptor via the shared ratio helper.
79/// Returns `None` (and logs) for a degenerate ratio so the caller's stream simply omits it.
80fn map_split(split: AlpacaStockSplit) -> Option<CorporateAction<SmolStr>> {
81    match CorporateActionKind::stock_split(split.new_rate, split.old_rate) {
82        // `effective_date` maps to Alpaca's `ex_date` — the split's market execution date, per the
83        // `StockSplitSource::fetch_splits` contract (NOT `payable_date`; see the impl rustdoc).
84        Some(kind) => Some(CorporateAction::new(
85            SmolStr::from(split.symbol),
86            kind,
87            Some(split.ex_date),
88        )),
89        None => {
90            warn!(
91                symbol = %split.symbol,
92                new_rate = %split.new_rate,
93                old_rate = %split.old_rate,
94                "Alpaca stock split has a degenerate ratio; skipping",
95            );
96            None
97        }
98    }
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used)] // Tests should panic on unexpected values
103mod tests {
104    use super::*;
105    use chrono::NaiveDate;
106    use rust_decimal::Decimal;
107    use rustrade_instrument::corporate_action::SplitRatio;
108
109    fn split(symbol: &str, new_rate: i64, old_rate: i64, ex_date: NaiveDate) -> AlpacaStockSplit {
110        AlpacaStockSplit {
111            symbol: symbol.to_string(),
112            ex_date,
113            new_rate: Decimal::from(new_rate),
114            old_rate: Decimal::from(old_rate),
115        }
116    }
117
118    #[test]
119    fn build_query_joins_symbols_and_dates() {
120        let filter = CorporateActionFilter::new(
121            vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")],
122            NaiveDate::from_ymd_opt(2024, 1, 1),
123            NaiveDate::from_ymd_opt(2024, 12, 31),
124        );
125
126        let query = build_query(&filter);
127        assert_eq!(
128            query.symbols,
129            vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")]
130        );
131        assert_eq!(query.start, filter.start);
132        assert_eq!(query.end, filter.end);
133    }
134
135    #[test]
136    fn build_query_empty_symbols_is_unrestricted() {
137        let filter = CorporateActionFilter::default();
138        let query = build_query(&filter);
139        assert!(query.symbols.is_empty());
140        assert!(query.start.is_none());
141        assert!(query.end.is_none());
142    }
143
144    #[test]
145    fn map_split_forward_ratio() {
146        let action = map_split(split(
147            "NVDA",
148            10,
149            1,
150            NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
151        ))
152        .unwrap();
153        assert_eq!(action.instrument, SmolStr::new("NVDA"));
154        assert_eq!(
155            action.kind,
156            CorporateActionKind::StockSplit {
157                ratio: SplitRatio::new(Decimal::from(10)).unwrap()
158            }
159        );
160    }
161
162    #[test]
163    fn map_split_reverse_ratio() {
164        // 1-for-25 reverse → ratio 0.04.
165        let action = map_split(split(
166            "ATRA",
167            1,
168            25,
169            NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(),
170        ))
171        .unwrap();
172        assert_eq!(
173            action.kind,
174            CorporateActionKind::StockSplit {
175                ratio: SplitRatio::new(Decimal::new(4, 2)).unwrap()
176            }
177        );
178    }
179
180    #[test]
181    fn map_split_maps_ex_date_to_effective_date() {
182        // Pins the provenance: `effective_date` is the raw split's `ex_date`. Paired with the
183        // `reference::ex_date_provenance_ignores_payable_date` deser test (which proves the parsed
184        // `ex_date` is the JSON `ex_date`, not `payable_date`), this covers the full chain.
185        let ex_date = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
186        let action = map_split(split("APH", 2, 1, ex_date)).unwrap();
187        assert_eq!(action.effective_date, Some(ex_date));
188    }
189
190    #[test]
191    fn map_split_drops_degenerate_ratio() {
192        // old_rate == 0 → division by zero → skipped.
193        assert!(
194            map_split(split(
195                "BAD",
196                1,
197                0,
198                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
199            ))
200            .is_none()
201        );
202    }
203}