use super::pagination::PaginationGuard;
use super::rest::{AlpacaRestClient, AlpacaRestError};
use async_stream::try_stream;
use chrono::NaiveDate;
use futures::Stream;
use rust_decimal::Decimal;
use serde::Deserialize;
use smol_str::SmolStr;
use tracing::debug;
const MAX_LIMIT: u16 = 1000;
const MAX_PAGES: usize = 1000;
const SPLIT_TYPES: &str = "forward_split,reverse_split";
#[derive(Debug, Default, Clone)]
pub struct CorporateActionsQuery {
pub symbols: Vec<SmolStr>,
pub start: Option<NaiveDate>,
pub end: Option<NaiveDate>,
pub limit: Option<u16>,
}
impl CorporateActionsQuery {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn symbols<I, S>(mut self, symbols: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<SmolStr>,
{
self.symbols = symbols.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn start(mut self, date: NaiveDate) -> Self {
self.start = Some(date);
self
}
#[must_use]
pub fn end(mut self, date: NaiveDate) -> Self {
self.end = Some(date);
self
}
#[must_use]
pub fn limit(mut self, limit: u16) -> Self {
self.limit = Some(limit.min(MAX_LIMIT));
self
}
fn to_query_params(&self) -> Vec<(&'static str, String)> {
let mut params = vec![("types", SPLIT_TYPES.to_string())];
if !self.symbols.is_empty() {
params.push(("symbols", self.symbols.join(",")));
}
if let Some(start) = self.start {
params.push(("start", start.format("%Y-%m-%d").to_string()));
}
if let Some(end) = self.end {
params.push(("end", end.format("%Y-%m-%d").to_string()));
}
let limit = self.limit.unwrap_or(MAX_LIMIT).min(MAX_LIMIT);
params.push(("limit", limit.to_string()));
params
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlpacaStockSplit {
pub symbol: String,
pub ex_date: NaiveDate,
pub new_rate: Decimal,
pub old_rate: Decimal,
}
#[derive(Debug, Default, Deserialize)]
struct CorporateActionsResponse {
#[serde(default)]
corporate_actions: CorporateActions,
#[serde(default)]
next_page_token: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct CorporateActions {
#[serde(default)]
forward_splits: Vec<RawSplit>,
#[serde(default)]
reverse_splits: Vec<RawSplit>,
}
impl CorporateActions {
fn into_stock_splits(self) -> impl Iterator<Item = AlpacaStockSplit> {
self.forward_splits
.into_iter()
.chain(self.reverse_splits)
.map(RawSplit::into_stock_split)
}
}
#[derive(Debug, Deserialize)]
struct RawSplit {
symbol: String,
#[serde(with = "rust_decimal::serde::float")]
new_rate: Decimal,
#[serde(with = "rust_decimal::serde::float")]
old_rate: Decimal,
ex_date: NaiveDate,
}
impl RawSplit {
fn into_stock_split(self) -> AlpacaStockSplit {
AlpacaStockSplit {
symbol: self.symbol,
ex_date: self.ex_date,
new_rate: self.new_rate,
old_rate: self.old_rate,
}
}
}
impl AlpacaRestClient {
pub fn fetch_splits_raw<'a>(
&'a self,
query: &'a CorporateActionsQuery,
) -> impl Stream<Item = Result<AlpacaStockSplit, AlpacaRestError>> + 'a {
try_stream! {
let url = format!("{}/v1beta1/corporate-actions", self.data_base());
let mut page_token: Option<String> = None;
let mut guard = PaginationGuard::new(MAX_PAGES);
loop {
guard.observe(page_token.as_deref())?;
let mut params = query.to_query_params();
if let Some(ref token) = page_token {
params.push(("page_token", token.clone()));
}
debug!(url = %url, page = guard.pages(), "Fetching Alpaca corporate-actions page");
let request = self.get(&url).query(¶ms);
let response: CorporateActionsResponse = self.request_with_retry(request).await?;
let CorporateActionsResponse { corporate_actions, next_page_token } = response;
for split in corporate_actions.into_stock_splits() {
yield split;
}
match next_page_token {
Some(token) if !token.is_empty() => page_token = Some(token),
_ => break,
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn query_empty_sets_only_types_and_limit() {
let params = CorporateActionsQuery::new().to_query_params();
assert!(params.contains(&("types", SPLIT_TYPES.to_string())));
assert!(params.iter().any(|(k, v)| *k == "limit" && v == "1000"));
assert!(!params.iter().any(|(k, _)| *k == "symbols"));
assert!(!params.iter().any(|(k, _)| *k == "start" || *k == "end"));
}
#[test]
fn query_joins_symbols_and_dates() {
let params = CorporateActionsQuery::new()
.symbols(["NVDA", "AAPL"])
.start(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
.end(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap())
.to_query_params();
assert!(
params
.iter()
.any(|(k, v)| *k == "symbols" && v == "NVDA,AAPL")
);
assert!(
params
.iter()
.any(|(k, v)| *k == "start" && v == "2024-01-01")
);
assert!(params.iter().any(|(k, v)| *k == "end" && v == "2024-12-31"));
}
#[test]
fn query_limit_clamped_to_max() {
let params = CorporateActionsQuery::new().limit(5000).to_query_params();
assert!(params.iter().any(|(k, v)| *k == "limit" && v == "1000"));
}
#[test]
fn documented_limits_match_their_constants() {
assert_eq!(MAX_LIMIT, 1000);
assert_eq!(MAX_PAGES, 1000);
}
#[test]
fn parse_forward_split_response() {
let json = r#"{
"corporate_actions": {
"forward_splits": [{
"symbol": "NVDA", "cusip": "67066G104",
"new_rate": 10, "old_rate": 1,
"ex_date": "2024-06-10", "process_date": "2024-06-10",
"payable_date": "2024-06-10", "record_date": "2024-06-07",
"due_bill_redemption_date": "2024-06-10",
"id": "50199fac-0af8-43ef-9846-eaf64c6d322d"
}]
},
"next_page_token": null
}"#;
let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
assert!(parsed.next_page_token.is_none());
let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
assert_eq!(splits.len(), 1);
assert_eq!(
splits[0],
AlpacaStockSplit {
symbol: "NVDA".to_string(),
ex_date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
new_rate: Decimal::from(10),
old_rate: Decimal::from(1),
}
);
}
#[test]
fn parse_reverse_split_response_with_dual_cusip() {
let json = r#"{
"corporate_actions": {
"reverse_splits": [{
"symbol": "ATRA", "new_rate": 1, "old_rate": 25,
"new_cusip": "046513206", "old_cusip": "046513107",
"ex_date": "2024-06-20", "process_date": "2024-06-20",
"payable_date": "2024-06-20", "record_date": "2024-06-20",
"id": "446f18f3-92fc-42a8-8b93-4700f06bc8e0"
}]
}
}"#;
let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].symbol, "ATRA");
assert_eq!(splits[0].new_rate, Decimal::from(1));
assert_eq!(splits[0].old_rate, Decimal::from(25));
assert_eq!(
splits[0].ex_date,
NaiveDate::from_ymd_opt(2024, 6, 20).unwrap()
);
}
#[test]
fn parse_both_arrays_forward_first_with_page_token() {
let json = r#"{
"corporate_actions": {
"forward_splits": [{
"symbol": "NVDA", "new_rate": 10, "old_rate": 1, "ex_date": "2024-06-10"
}],
"reverse_splits": [{
"symbol": "ATRA", "new_rate": 1, "old_rate": 25, "ex_date": "2024-06-20"
}]
},
"next_page_token": "next123"
}"#;
let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.next_page_token.as_deref(), Some("next123"));
let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
assert_eq!(splits[0].symbol, "NVDA");
assert_eq!(splits[1].symbol, "ATRA");
}
#[test]
fn parse_empty_response() {
let json = r#"{ "corporate_actions": {}, "next_page_token": null }"#;
let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.corporate_actions.into_stock_splits().count(), 0);
}
#[test]
fn ex_date_provenance_ignores_payable_date() {
let json = r#"{
"corporate_actions": {
"forward_splits": [{
"symbol": "APH", "new_rate": 2, "old_rate": 1,
"ex_date": "2024-06-12", "process_date": "2024-06-12",
"payable_date": "2024-06-11", "record_date": "2024-05-31"
}]
}
}"#;
let parsed: CorporateActionsResponse = serde_json::from_str(json).unwrap();
let splits: Vec<_> = parsed.corporate_actions.into_stock_splits().collect();
assert_eq!(splits.len(), 1);
assert_eq!(
splits[0].ex_date,
NaiveDate::from_ymd_opt(2024, 6, 12).unwrap(),
"ex_date must be the JSON ex_date, never payable_date (2024-06-11)"
);
}
}