use async_stream::try_stream;
use futures::{Stream, StreamExt};
use smol_str::SmolStr;
use tracing::warn;
use rustrade_instrument::corporate_action::{CorporateAction, CorporateActionKind};
use rustrade_integration::corporate_action::{CorporateActionFilter, StockSplitSource};
use super::reference::{AlpacaStockSplit, CorporateActionsQuery};
use super::rest::{AlpacaRestClient, AlpacaRestError};
impl StockSplitSource for AlpacaRestClient {
type Error = AlpacaRestError;
fn fetch_splits(
&self,
filter: &CorporateActionFilter,
) -> impl Stream<Item = Result<CorporateAction<SmolStr>, Self::Error>> + Send {
let query = build_query(filter);
try_stream! {
let raw = self.fetch_splits_raw(&query);
futures::pin_mut!(raw);
while let Some(split) = raw.next().await {
if let Some(action) = map_split(split?) {
yield action;
}
}
}
}
}
fn build_query(filter: &CorporateActionFilter) -> CorporateActionsQuery {
let mut query = CorporateActionsQuery::new();
if !filter.symbols.is_empty() {
query = query.symbols(filter.symbols.iter().map(SmolStr::as_str));
}
if let Some(start) = filter.start {
query = query.start(start);
}
if let Some(end) = filter.end {
query = query.end(end);
}
query
}
fn map_split(split: AlpacaStockSplit) -> Option<CorporateAction<SmolStr>> {
match CorporateActionKind::stock_split(split.new_rate, split.old_rate) {
Some(kind) => Some(CorporateAction::new(
SmolStr::from(split.symbol),
kind,
Some(split.ex_date),
)),
None => {
warn!(
symbol = %split.symbol,
new_rate = %split.new_rate,
old_rate = %split.old_rate,
"Alpaca stock split has a degenerate ratio; skipping",
);
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use rustrade_instrument::corporate_action::SplitRatio;
fn split(symbol: &str, new_rate: i64, old_rate: i64, ex_date: NaiveDate) -> AlpacaStockSplit {
AlpacaStockSplit {
symbol: symbol.to_string(),
ex_date,
new_rate: Decimal::from(new_rate),
old_rate: Decimal::from(old_rate),
}
}
#[test]
fn build_query_joins_symbols_and_dates() {
let filter = CorporateActionFilter::new(
vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")],
NaiveDate::from_ymd_opt(2024, 1, 1),
NaiveDate::from_ymd_opt(2024, 12, 31),
);
let query = build_query(&filter);
assert_eq!(
query.symbols,
vec![SmolStr::new("NVDA"), SmolStr::new("AAPL")]
);
assert_eq!(query.start, filter.start);
assert_eq!(query.end, filter.end);
}
#[test]
fn build_query_empty_symbols_is_unrestricted() {
let filter = CorporateActionFilter::default();
let query = build_query(&filter);
assert!(query.symbols.is_empty());
assert!(query.start.is_none());
assert!(query.end.is_none());
}
#[test]
fn map_split_forward_ratio() {
let action = map_split(split(
"NVDA",
10,
1,
NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
))
.unwrap();
assert_eq!(action.instrument, SmolStr::new("NVDA"));
assert_eq!(
action.kind,
CorporateActionKind::StockSplit {
ratio: SplitRatio::new(Decimal::from(10)).unwrap()
}
);
}
#[test]
fn map_split_reverse_ratio() {
let action = map_split(split(
"ATRA",
1,
25,
NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(),
))
.unwrap();
assert_eq!(
action.kind,
CorporateActionKind::StockSplit {
ratio: SplitRatio::new(Decimal::new(4, 2)).unwrap()
}
);
}
#[test]
fn map_split_maps_ex_date_to_effective_date() {
let ex_date = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
let action = map_split(split("APH", 2, 1, ex_date)).unwrap();
assert_eq!(action.effective_date, Some(ex_date));
}
#[test]
fn map_split_drops_degenerate_ratio() {
assert!(
map_split(split(
"BAD",
1,
0,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
))
.is_none()
);
}
}